Add normalizers, fix tests

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-develop-3zknud/deployments/243^2
marcin mikołajczak 2022-06-08 23:43:27 +02:00
rodzic 6c45dcb109
commit a66c174c2d
11 zmienionych plików z 174 dodań i 92 usunięć

Wyświetl plik

@ -10,16 +10,14 @@ import { shortNumberFormat } from '../utils/numbers';
import Permalink from './permalink'; import Permalink from './permalink';
import { HStack, Stack, Text } from './ui'; import { HStack, Stack, Text } from './ui';
import type { Hashtag as HashtagEntity } from 'soapbox/reducers/search'; import type { Tag } from 'soapbox/types/entities';
import type { TrendingHashtag } from 'soapbox/reducers/trends';
interface IHashtag { interface IHashtag {
hashtag: HashtagEntity | TrendingHashtag, hashtag: Tag,
} }
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => { const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
const history = (hashtag as TrendingHashtag).history; const count = Number(hashtag.history?.get(0)?.accounts);
const count = Number(history?.get(0)?.accounts);
const brandColor = useSelector((state) => getSoapboxConfig(state).brandColor); const brandColor = useSelector((state) => getSoapboxConfig(state).brandColor);
return ( return (
@ -29,7 +27,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text> <Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
</Permalink> </Permalink>
{history && ( {hashtag.history && (
<Text theme='muted' size='sm'> <Text theme='muted' size='sm'>
<FormattedMessage <FormattedMessage
id='trends.count_by_accounts' id='trends.count_by_accounts'
@ -43,12 +41,12 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
)} )}
</Stack> </Stack>
{history && ( {hashtag.history && (
<div className='w-[40px]' data-testid='sparklines'> <div className='w-[40px]' data-testid='sparklines'>
<Sparklines <Sparklines
width={40} width={40}
height={28} height={28}
data={history.reverse().map((day) => +day.uses).toArray()} data={hashtag.history.reverse().map((day) => +day.uses).toArray()}
> >
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} /> <SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
</Sparklines> </Sparklines>

Wyświetl plik

@ -1,22 +1,26 @@
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import React from 'react'; import React from 'react';
import { render, screen } from '../../../../jest/test-helpers'; import { render, screen } from '../../../../jest/test-helpers';
import { normalizeTag } from '../../../../normalizers';
import TrendsPanel from '../trends-panel'; import TrendsPanel from '../trends-panel';
describe('<TrendsPanel />', () => { describe('<TrendsPanel />', () => {
it('renders trending hashtags', () => { it('renders trending hashtags', () => {
const store = { const store = {
trends: { trends: ImmutableRecord({
items: ImmutableList([{ items: ImmutableList([
name: 'hashtag 1', normalizeTag({
history: ImmutableList([{ name: 'hashtag 1',
day: '1652745600', history: [{
uses: '294', day: '1652745600',
accounts: '180', uses: '294',
}]), accounts: '180',
}]), }],
}, }),
]),
isLoading: false,
})(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, null, store);
@ -27,18 +31,19 @@ describe('<TrendsPanel />', () => {
it('renders multiple trends', () => { it('renders multiple trends', () => {
const store = { const store = {
trends: { trends: ImmutableRecord({
items: ImmutableList([ items: ImmutableList([
{ normalizeTag({
name: 'hashtag 1', name: 'hashtag 1',
history: ImmutableList([{ accounts: [] }]), history: ImmutableList([{ accounts: [] }]),
}, }),
{ normalizeTag({
name: 'hashtag 2', name: 'hashtag 2',
history: ImmutableList([{ accounts: [] }]), history: ImmutableList([{ accounts: [] }]),
}, }),
]), ]),
}, isLoading: false,
})(),
}; };
render(<TrendsPanel limit={3} />, null, store); render(<TrendsPanel limit={3} />, null, store);
@ -47,18 +52,19 @@ describe('<TrendsPanel />', () => {
it('respects the limit prop', () => { it('respects the limit prop', () => {
const store = { const store = {
trends: { trends: ImmutableRecord({
items: ImmutableList([ items: ImmutableList([
{ normalizeTag({
name: 'hashtag 1', name: 'hashtag 1',
history: ImmutableList([{ accounts: [] }]), history: [{ accounts: [] }],
}, }),
{ normalizeTag({
name: 'hashtag 2', name: 'hashtag 2',
history: ImmutableList([{ accounts: [] }]), history: [{ accounts: [] }],
}, }),
]), ]),
}, isLoading: false,
})(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, null, store);
@ -67,9 +73,10 @@ describe('<TrendsPanel />', () => {
it('renders empty', () => { it('renders empty', () => {
const store = { const store = {
trends: { trends: ImmutableRecord({
items: ImmutableList([]), items: ImmutableList([]),
}, isLoading: false,
})(),
}; };
render(<TrendsPanel limit={1} />, null, store); render(<TrendsPanel limit={1} />, null, store);

Wyświetl plik

@ -1,5 +1,5 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; import { Map as ImmutableMap, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable';
import React from 'react'; import React from 'react';
import { __stub } from 'soapbox/api'; import { __stub } from 'soapbox/api';
@ -24,13 +24,13 @@ describe('<ReportModal />', () => {
avatar: 'test.jpg', avatar: 'test.jpg',
}), }),
}), }),
reports: ImmutableMap({ reports: ImmutableRecord({
new: { new: ImmutableRecord({
account_id: '1', account_id: '1',
status_ids: ImmutableSet(['1']), status_ids: ImmutableSet(['1']),
rule_ids: ImmutableSet(), rule_ids: ImmutableSet(),
}, })(),
}), })(),
statuses: ImmutableMap({ statuses: ImmutableMap({
'1': normalizeStatus(status), '1': normalizeStatus(status),
}), }),

Wyświetl plik

@ -14,7 +14,7 @@ interface ITrendsPanel {
const TrendsPanel = ({ limit }: ITrendsPanel) => { const TrendsPanel = ({ limit }: ITrendsPanel) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const trends = useAppSelector((state) => state.trends.get('items')); const trends = useAppSelector((state) => state.trends.items);
const sortedTrends = React.useMemo(() => { const sortedTrends = React.useMemo(() => {
return trends.sort((a, b) => { return trends.sort((a, b) => {

Wyświetl plik

@ -0,0 +1,22 @@
/**
* History normalizer:
* Converts API daily usage history of a hashtag into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/history/}
*/
import {
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
// https://docs.joinmastodon.org/entities/history/
export const HistoryRecord = ImmutableRecord({
accounts: '',
day: '',
uses: '',
});
export const normalizeHistory = (history: Record<string, any>) => {
return HistoryRecord(
ImmutableMap(fromJS(history)),
);
};

Wyświetl plik

@ -6,6 +6,7 @@ export { CardRecord, normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat'; export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { ChatMessageRecord, normalizeChatMessage } from './chat_message';
export { EmojiRecord, normalizeEmoji } from './emoji'; export { EmojiRecord, normalizeEmoji } from './emoji';
export { HistoryRecord, normalizeHistory } from './history';
export { InstanceRecord, normalizeInstance } from './instance'; export { InstanceRecord, normalizeInstance } from './instance';
export { ListRecord, normalizeList } from './list'; export { ListRecord, normalizeList } from './list';
export { MentionRecord, normalizeMention } from './mention'; export { MentionRecord, normalizeMention } from './mention';
@ -14,5 +15,6 @@ export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
export { RelationshipRecord, normalizeRelationship } from './relationship'; export { RelationshipRecord, normalizeRelationship } from './relationship';
export { StatusRecord, normalizeStatus } from './status'; export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status_edit'; export { StatusEditRecord, normalizeStatusEdit } from './status_edit';
export { TagRecord, normalizeTag } from './tag';
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config'; export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config';

Wyświetl plik

@ -0,0 +1,40 @@
/**
* Tag normalizer:
* Converts API tags into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/tag/}
*/
import {
List as ImmutableList,
Map as ImmutableMap,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import { normalizeHistory } from './history';
import type { History } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/tag/
export const TagRecord = ImmutableRecord({
name: '',
url: '',
history: null as ImmutableList<History> | null,
});
const normalizeHistoryList = (tag: ImmutableMap<string, any>) => {
if (tag.get('history')){
return tag.update('history', ImmutableList(), attachments => {
return attachments.map(normalizeHistory);
});
} else {
return tag.set('history', null);
}
};
export const normalizeTag = (tag: Record<string, any>) => {
return TagRecord(
ImmutableMap(fromJS(tag)).withMutations(tag => {
normalizeHistoryList(tag);
}),
);
};

Wyświetl plik

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { import {
SEARCH_CHANGE, SEARCH_CHANGE,
@ -10,14 +10,24 @@ import reducer from '../search';
describe('search reducer', () => { describe('search reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(ImmutableMap({ expect(reducer(undefined, {}).toJS()).toEqual({
value: '', value: '',
submitted: false, submitted: false,
submittedValue: '', submittedValue: '',
hidden: false, hidden: false,
results: ImmutableMap(), results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts', filter: 'accounts',
})); });
}); });
describe('SEARCH_CHANGE', () => { describe('SEARCH_CHANGE', () => {
@ -30,42 +40,54 @@ describe('search reducer', () => {
describe('SEARCH_CLEAR', () => { describe('SEARCH_CLEAR', () => {
it('resets the state', () => { it('resets the state', () => {
const state = ImmutableMap({ const state = ImmutableRecord({
value: 'hello world', value: 'hello world',
submitted: true, submitted: true,
submittedValue: 'hello world', submittedValue: 'hello world',
hidden: false, hidden: false,
results: ImmutableMap(), results: ImmutableRecord({})(),
filter: 'statuses', filter: 'statuses',
}); })();
const action = { type: SEARCH_CLEAR }; const action = { type: SEARCH_CLEAR };
const expected = ImmutableMap({ const expected = {
value: '', value: '',
submitted: false, submitted: false,
submittedValue: '', submittedValue: '',
hidden: false, hidden: false,
results: ImmutableMap(), results: {
accounts: [],
statuses: [],
hashtags: [],
accountsHasMore: false,
statusesHasMore: false,
hashtagsHasMore: false,
accountsLoaded: false,
statusesLoaded: false,
hashtagsLoaded: false,
},
filter: 'accounts', filter: 'accounts',
}); };
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action).toJS()).toEqual(expected);
}); });
}); });
describe(SEARCH_EXPAND_SUCCESS, () => { describe(SEARCH_EXPAND_SUCCESS, () => {
it('imports hashtags as maps', () => { it('imports hashtags as maps', () => {
const state = ImmutableMap({ const state = ImmutableRecord({
value: 'artist', value: 'artist',
submitted: true, submitted: true,
submittedValue: 'artist', submittedValue: 'artist',
hidden: false, hidden: false,
results: ImmutableMap({ results: ImmutableRecord({
hashtags: ImmutableList(), hashtags: ImmutableList(),
}), hashtagsHasMore: false,
hashtagsLoaded: true,
})(),
filter: 'hashtags', filter: 'hashtags',
}); })();
const action = { const action = {
type: SEARCH_EXPAND_SUCCESS, type: SEARCH_EXPAND_SUCCESS,
@ -82,24 +104,26 @@ describe('search reducer', () => {
searchType: 'hashtags', searchType: 'hashtags',
}; };
const expected = ImmutableMap({ const expected = {
value: 'artist', value: 'artist',
submitted: true, submitted: true,
submittedValue: 'artist', submittedValue: 'artist',
hidden: false, hidden: false,
results: ImmutableMap({ results: {
hashtags: fromJS([{ hashtags: [
name: 'artist', {
url: 'https://gleasonator.com/tags/artist', name: 'artist',
history: [], url: 'https://gleasonator.com/tags/artist',
}]), history: [],
},
],
hashtagsHasMore: false, hashtagsHasMore: false,
hashtagsLoaded: true, hashtagsLoaded: true,
}), },
filter: 'hashtags', filter: 'hashtags',
}); };
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action).toJS()).toEqual(expected);
}); });
}); });
}); });

Wyświetl plik

@ -1,6 +1,6 @@
import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable'; import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable';
import { APIEntity } from 'soapbox/types/entities'; import { normalizeTag } from 'soapbox/normalizers';
import { import {
COMPOSE_MENTION, COMPOSE_MENTION,
@ -20,16 +20,12 @@ import {
} from '../actions/search'; } from '../actions/search';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { APIEntity, Tag } from 'soapbox/types/entities';
const HashtagRecord = ImmutableRecord({
name: '',
url: '',
});
const ResultsRecord = ImmutableRecord({ const ResultsRecord = ImmutableRecord({
accounts: ImmutableOrderedSet<string>(), accounts: ImmutableOrderedSet<string>(),
statuses: ImmutableOrderedSet<string>(), statuses: ImmutableOrderedSet<string>(),
hashtags: ImmutableOrderedSet<Hashtag>(), // it's a list of maps hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
accountsHasMore: false, accountsHasMore: false,
statusesHasMore: false, statusesHasMore: false,
hashtagsHasMore: false, hashtagsHasMore: false,
@ -49,7 +45,6 @@ const ReducerRecord = ImmutableRecord({
type State = ReturnType<typeof ReducerRecord>; type State = ReturnType<typeof ReducerRecord>;
type APIEntities = Array<APIEntity>; type APIEntities = Array<APIEntity>;
export type Hashtag = ReturnType<typeof HashtagRecord>;
export type SearchFilter = 'accounts' | 'statuses' | 'hashtags'; export type SearchFilter = 'accounts' | 'statuses' | 'hashtags';
const toIds = (items: APIEntities) => { const toIds = (items: APIEntities) => {
@ -62,7 +57,7 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea
state.set('results', ResultsRecord({ state.set('results', ResultsRecord({
accounts: toIds(results.accounts), accounts: toIds(results.accounts),
statuses: toIds(results.statuses), statuses: toIds(results.statuses),
hashtags: ImmutableOrderedSet(results.hashtags.map(HashtagRecord)), // it's a list of maps hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records
accountsHasMore: results.accounts.length >= 20, accountsHasMore: results.accounts.length >= 20,
statusesHasMore: results.statuses.length >= 20, statusesHasMore: results.statuses.length >= 20,
hashtagsHasMore: results.hashtags.length >= 20, hashtagsHasMore: results.hashtags.length >= 20,

Wyświetl plik

@ -1,5 +1,7 @@
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
import { normalizeTag } from 'soapbox/normalizers';
import { import {
TRENDS_FETCH_REQUEST, TRENDS_FETCH_REQUEST,
TRENDS_FETCH_SUCCESS, TRENDS_FETCH_SUCCESS,
@ -7,28 +9,14 @@ import {
} from '../actions/trends'; } from '../actions/trends';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity, Tag } from 'soapbox/types/entities';
const HistoryRecord = ImmutableRecord({
accounts: '',
day: '',
uses: '',
});
const TrendingHashtagRecord = ImmutableRecord({
name: '',
url: '',
history: ImmutableList<History>(),
});
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
items: ImmutableList<TrendingHashtag>(), items: ImmutableList<Tag>(),
isLoading: false, isLoading: false,
}); });
type State = ReturnType<typeof ReducerRecord>; type State = ReturnType<typeof ReducerRecord>;
type History = ReturnType<typeof HistoryRecord>;
export type TrendingHashtag = ReturnType<typeof TrendingHashtagRecord>;
export default function trendsReducer(state: State = ReducerRecord(), action: AnyAction) { export default function trendsReducer(state: State = ReducerRecord(), action: AnyAction) {
switch (action.type) { switch (action.type) {
@ -36,7 +24,7 @@ export default function trendsReducer(state: State = ReducerRecord(), action: An
return state.set('isLoading', true); return state.set('isLoading', true);
case TRENDS_FETCH_SUCCESS: case TRENDS_FETCH_SUCCESS:
return state.withMutations(map => { return state.withMutations(map => {
map.set('items', ImmutableList(action.tags.map((item: APIEntity) => TrendingHashtagRecord({ ...item, history: ImmutableList(item.history.map(HistoryRecord)) })))); map.set('items', ImmutableList(action.tags.map((item: APIEntity) => normalizeTag(item))));
map.set('isLoading', false); map.set('isLoading', false);
}); });
case TRENDS_FETCH_FAIL: case TRENDS_FETCH_FAIL:

Wyświetl plik

@ -8,6 +8,7 @@ import {
ChatMessageRecord, ChatMessageRecord,
EmojiRecord, EmojiRecord,
FieldRecord, FieldRecord,
HistoryRecord,
InstanceRecord, InstanceRecord,
ListRecord, ListRecord,
MentionRecord, MentionRecord,
@ -17,6 +18,7 @@ import {
RelationshipRecord, RelationshipRecord,
StatusEditRecord, StatusEditRecord,
StatusRecord, StatusRecord,
TagRecord,
} from 'soapbox/normalizers'; } from 'soapbox/normalizers';
import type { Record as ImmutableRecord } from 'immutable'; import type { Record as ImmutableRecord } from 'immutable';
@ -29,6 +31,7 @@ type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>; type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>; type Emoji = ReturnType<typeof EmojiRecord>;
type Field = ReturnType<typeof FieldRecord>; type Field = ReturnType<typeof FieldRecord>;
type History = ReturnType<typeof HistoryRecord>;
type Instance = ReturnType<typeof InstanceRecord>; type Instance = ReturnType<typeof InstanceRecord>;
type List = ReturnType<typeof ListRecord>; type List = ReturnType<typeof ListRecord>;
type Mention = ReturnType<typeof MentionRecord>; type Mention = ReturnType<typeof MentionRecord>;
@ -37,6 +40,7 @@ type Poll = ReturnType<typeof PollRecord>;
type PollOption = ReturnType<typeof PollOptionRecord>; type PollOption = ReturnType<typeof PollOptionRecord>;
type Relationship = ReturnType<typeof RelationshipRecord>; type Relationship = ReturnType<typeof RelationshipRecord>;
type StatusEdit = ReturnType<typeof StatusEditRecord>; type StatusEdit = ReturnType<typeof StatusEditRecord>;
type Tag = ReturnType<typeof TagRecord>;
interface Account extends ReturnType<typeof AccountRecord> { interface Account extends ReturnType<typeof AccountRecord> {
// HACK: we can't do a circular reference in the Record definition itself, // HACK: we can't do a circular reference in the Record definition itself,
@ -64,6 +68,7 @@ export {
ChatMessage, ChatMessage,
Emoji, Emoji,
Field, Field,
History,
Instance, Instance,
List, List,
Mention, Mention,
@ -73,6 +78,7 @@ export {
Relationship, Relationship,
Status, Status,
StatusEdit, StatusEdit,
Tag,
// Utility types // Utility types
APIEntity, APIEntity,