Normalize poll with zod

environments/review-zod-poll-43mlmz/deployments/3321
Alex Gleason 1 year ago
parent 211fdd52f5
commit d4ed442a7e
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { __stub } from 'soapbox/api';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers';
import { type Poll } from 'soapbox/schemas';
import { mockStore, render, screen, rootState } from '../../../jest/test-helpers';
import PollFooter from '../poll-footer';
let poll = normalizePoll({
id: 1,
options: [{ title: 'Apples', votes_count: 0 }],
let poll: Poll = {
id: '1',
options: [{
title: 'Apples',
votes_count: 0,
title_emojified: 'Apples',
}, {
title: 'Oranges',
votes_count: 0,
title_emojified: 'Oranges',
}],
emojis: [],
expired: false,
expires_at: '2020-03-24T19:33:06.000Z',
@ -20,7 +28,7 @@ let poll = normalizePoll({
votes_count: 0,
own_votes: null,
voted: false,
});
};
describe('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has not expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: false,
});
};
});
it('renders time remaining', () => {
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: true,
});
};
});
it('renders closed', () => {
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: true,
});
};
});
it('renders the Vote button', () => {
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: false,
});
};
});
it('does not render the Vote button', () => {

@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
let votesCount = null;
if (poll.voters_count !== null && poll.voters_count !== undefined) {
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.voters_count }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (
{(!showResults && poll.multiple) && (
<Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
)}
<HStack space={1.5} alignItems='center' wrap>
{poll.pleroma.get('non_anonymous') && (
{poll.pleroma?.non_anonymous && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text theme='muted' weight='medium'>

@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const pollVotesCount = poll.voters_count || poll.votes_count;
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
const voted = poll.own_votes?.includes(index);
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
const leading = poll.options
.filter(other => other.title !== option.title)
.every(other => option.votes_count >= other.votes_count);
return (
<div key={option.title}>
{showResults ? (

@ -1,47 +0,0 @@
import { Record as ImmutableRecord } from 'immutable';
import { normalizePoll } from '../poll';
describe('normalizePoll()', () => {
it('adds base fields', () => {
const poll = { options: [{ title: 'Apples' }] };
const result = normalizePoll(poll);
const expected = {
options: [{ title: 'Apples', votes_count: 0 }],
emojis: [],
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
};
expect(ImmutableRecord.isRecord(result)).toBe(true);
expect(ImmutableRecord.isRecord(result.options.get(0))).toBe(true);
expect(result.toJS()).toMatchObject(expected);
});
it('normalizes a Pleroma logged-out poll', () => {
const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json');
const result = normalizePoll(poll);
// Adds logged-in fields
expect(result.voted).toBe(false);
expect(result.own_votes).toBe(null);
});
it('normalizes poll with emojis', () => {
const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
const result = normalizePoll(poll);
// Emojifies poll options
expect(result.options.get(1)?.title_emojified)
.toContain('emojione');
// Parses emojis as Immutable.Record's
expect(ImmutableRecord.isRecord(result.emojis.get(0))).toBe(true);
expect(result.emojis.get(1)?.shortcode).toEqual('soapbox');
});
});

@ -146,12 +146,16 @@ describe('normalizeStatus()', () => {
});
it('normalizes poll and poll options', () => {
const status = { poll: { options: [{ title: 'Apples' }] } };
const status = { poll: { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] } };
const result = normalizeStatus(status);
const poll = result.poll as Poll;
const expected = {
options: [{ title: 'Apples', votes_count: 0 }],
id: '1',
options: [
{ title: 'Apples', votes_count: 0 },
{ title: 'Oranges', votes_count: 0 },
],
emojis: [],
expired: false,
multiple: false,
@ -161,9 +165,7 @@ describe('normalizeStatus()', () => {
voted: false,
};
expect(ImmutableRecord.isRecord(poll)).toBe(true);
expect(ImmutableRecord.isRecord(poll.options.get(0))).toBe(true);
expect(poll.toJS()).toMatchObject(expected);
expect(poll).toMatchObject(expected);
});
it('normalizes a Pleroma logged-out poll', () => {
@ -182,12 +184,10 @@ describe('normalizeStatus()', () => {
const poll = result.poll as Poll;
// Emojifies poll options
expect(poll.options.get(1)?.title_emojified)
expect(poll.options[1].title_emojified)
.toContain('emojione');
// Parses emojis as Immutable.Record's
expect(ImmutableRecord.isRecord(poll.emojis.get(0))).toBe(true);
expect(poll.emojis.get(1)?.shortcode).toEqual('soapbox');
expect(poll.emojis[1].shortcode).toEqual('soapbox');
});
it('normalizes a card', () => {

@ -18,7 +18,6 @@ export { ListRecord, normalizeList } from './list';
export { LocationRecord, normalizeLocation } from './location';
export { MentionRecord, normalizeMention } from './mention';
export { NotificationRecord, normalizeNotification } from './notification';
export { PollRecord, PollOptionRecord, normalizePoll } from './poll';
export { StatusRecord, normalizeStatus } from './status';
export { StatusEditRecord, normalizeStatusEdit } from './status-edit';
export { TagRecord, normalizeTag } from './tag';

@ -1,102 +0,0 @@
/**
* Poll normalizer:
* Converts API polls into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/poll/}
*/
import escapeTextContentForBrowser from 'escape-html';
import {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import emojify from 'soapbox/features/emoji';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { makeEmojiMap } from 'soapbox/utils/normalizers';
import type { Emoji, PollOption } from 'soapbox/types/entities';
// https://docs.joinmastodon.org/entities/poll/
export const PollRecord = ImmutableRecord({
emojis: ImmutableList<Emoji>(),
expired: false,
expires_at: '',
id: '',
multiple: false,
options: ImmutableList<PollOption>(),
voters_count: 0,
votes_count: 0,
own_votes: null as ImmutableList<number> | null,
voted: false,
pleroma: ImmutableMap<string, any>(),
});
// Sub-entity of Poll
export const PollOptionRecord = ImmutableRecord({
title: '',
votes_count: 0,
// Internal fields
title_emojified: '',
});
// Normalize emojis
const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
return entity.update('emojis', ImmutableList(), emojis => {
return emojis.map(normalizeEmoji);
});
};
const normalizePollOption = (option: ImmutableMap<string, any> | string, emojis: ImmutableList<ImmutableMap<string, string>> = ImmutableList()) => {
const emojiMap = makeEmojiMap(emojis);
if (typeof option === 'string') {
const titleEmojified = emojify(escapeTextContentForBrowser(option), emojiMap);
return PollOptionRecord({
title: option,
title_emojified: titleEmojified,
});
}
const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
return PollOptionRecord(
option.set('title_emojified', titleEmojified),
);
};
// Normalize poll options
const normalizePollOptions = (poll: ImmutableMap<string, any>) => {
const emojis = poll.get('emojis');
return poll.update('options', (options: ImmutableList<ImmutableMap<string, any>>) => {
return options.map(option => normalizePollOption(option, emojis));
});
};
// Normalize own_votes to `null` if empty (like Mastodon)
const normalizePollOwnVotes = (poll: ImmutableMap<string, any>) => {
return poll.update('own_votes', ownVotes => {
return ownVotes?.size > 0 ? ownVotes : null;
});
};
// Whether the user voted in the poll
const normalizePollVoted = (poll: ImmutableMap<string, any>) => {
return poll.update('voted', voted => {
return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0;
});
};
export const normalizePoll = (poll: Record<string, any>) => {
return PollRecord(
ImmutableMap(fromJS(poll)).withMutations((poll: ImmutableMap<string, any>) => {
normalizeEmojis(poll);
normalizePollOptions(poll);
normalizePollOwnVotes(poll);
normalizePollVoted(poll);
}),
);
};

@ -12,7 +12,7 @@ import {
import emojify from 'soapbox/features/emoji';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { pollSchema } from 'soapbox/schemas';
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
import { makeEmojiMap } from 'soapbox/utils/normalizers';
@ -50,9 +50,10 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (statusEdit: ImmutableMap<string, any>) => {
if (statusEdit.hasIn(['poll', 'options'])) {
return statusEdit.update('poll', ImmutableMap(), normalizePoll);
} else {
try {
const poll = pollSchema.parse(statusEdit.get('poll').toJS());
return statusEdit.set('poll', poll);
} catch (_e) {
return statusEdit.set('poll', null);
}
};

@ -13,8 +13,7 @@ import {
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { cardSchema } from 'soapbox/schemas/card';
import { cardSchema, pollSchema } from 'soapbox/schemas';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
@ -109,9 +108,10 @@ const normalizeEmojis = (entity: ImmutableMap<string, any>) => {
// Normalize the poll in the status, if applicable
const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
if (status.hasIn(['poll', 'options'])) {
return status.update('poll', ImmutableMap(), normalizePoll);
} else {
try {
const poll = pollSchema.parse(status.get('poll').toJS());
return status.set('poll', poll);
} catch (_e) {
return status.set('poll', null);
}
};

@ -11,14 +11,17 @@ describe('polls reducer', () => {
describe('POLLS_IMPORT', () => {
it('normalizes the poll', () => {
const polls = [{ id: '3', options: [{ title: 'Apples' }] }];
const polls = [{ id: '3', options: [{ title: 'Apples' }, { title: 'Oranges' }] }];
const action = { type: POLLS_IMPORT, polls };
const result = reducer(undefined, action);
const expected = {
'3': {
options: [{ title: 'Apples', votes_count: 0 }],
options: [
{ title: 'Apples', votes_count: 0 },
{ title: 'Oranges', votes_count: 0 },
],
emojis: [],
expired: false,
multiple: false,

@ -74,11 +74,11 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => {
};
// Gets titles of poll options from status
const getPollOptionTitles = ({ poll }: StatusRecord): ImmutableList<string> => {
const getPollOptionTitles = ({ poll }: StatusRecord): readonly string[] => {
if (poll && typeof poll === 'object') {
return poll.options.map(({ title }) => title);
} else {
return ImmutableList();
return [];
}
};

@ -0,0 +1,44 @@
import { pollSchema } from '../poll';
describe('normalizePoll()', () => {
it('adds base fields', () => {
const poll = { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] };
const result = pollSchema.parse(poll);
const expected = {
options: [
{ title: 'Apples', votes_count: 0 },
{ title: 'Oranges', votes_count: 0 },
],
emojis: [],
expired: false,
multiple: false,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
};
expect(result).toMatchObject(expected);
});
it('normalizes a Pleroma logged-out poll', () => {
const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json');
const result = pollSchema.parse(poll);
// Adds logged-in fields
expect(result.voted).toBe(false);
expect(result.own_votes).toBe(null);
});
it('normalizes poll with emojis', () => {
const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
const result = pollSchema.parse(poll);
// Emojifies poll options
expect(result.options[1]?.title_emojified)
.toContain('emojione');
expect(result.emojis[1]?.shortcode).toEqual('soapbox');
});
});

@ -43,8 +43,8 @@ const accountSchema = z.object({
pleroma: z.any(), // TODO
source: z.any(), // TODO
statuses_count: z.number().catch(0),
uri: z.string().catch(''),
url: z.string().catch(''),
uri: z.string().url().catch(''),
url: z.string().url().catch(''),
username: z.string().catch(''),
verified: z.boolean().default(false),
website: z.string().catch(''),

@ -6,6 +6,7 @@ export { groupSchema, type Group } from './group';
export { groupMemberSchema, type GroupMember } from './group-member';
export { groupRelationshipSchema, type GroupRelationship } from './group-relationship';
export { groupTagSchema, type GroupTag } from './group-tag';
export { pollSchema, type Poll, type PollOption } from './poll';
export { relationshipSchema, type Relationship } from './relationship';
// Soapbox

@ -0,0 +1,50 @@
import escapeTextContentForBrowser from 'escape-html';
import { z } from 'zod';
import emojify from 'soapbox/features/emoji';
import { customEmojiSchema } from './custom-emoji';
import { filteredArray, makeCustomEmojiMap } from './utils';
const pollOptionSchema = z.object({
title: z.string().catch(''),
votes_count: z.number().catch(0),
});
const pollSchema = z.object({
emojis: filteredArray(customEmojiSchema),
expired: z.boolean().catch(false),
expires_at: z.string().datetime().catch(new Date().toUTCString()),
id: z.string(),
multiple: z.boolean().catch(false),
options: z.array(pollOptionSchema).min(2),
voters_count: z.number().catch(0),
votes_count: z.number().catch(0),
own_votes: z.array(z.number()).nonempty().nullable().catch(null),
voted: z.boolean().catch(false),
pleroma: z.object({
non_anonymous: z.boolean().catch(false),
}).optional().catch(undefined),
}).transform((poll) => {
const emojiMap = makeCustomEmojiMap(poll.emojis);
const emojifiedOptions = poll.options.map((option) => ({
...option,
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
// If the user has votes, they have certainly voted.
if (poll.own_votes?.length) {
poll.voted = true;
}
return {
...poll,
options: emojifiedOptions,
};
});
type Poll = z.infer<typeof pollSchema>;
type PollOption = Poll['options'][number];
export { pollSchema, type Poll, type PollOption };

@ -5,7 +5,7 @@ import { cardSchema } from '../card';
const adSchema = z.object({
card: cardSchema,
impression: z.string().optional().catch(undefined),
expires_at: z.string().optional().catch(undefined),
expires_at: z.string().datetime().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
});

@ -18,8 +18,6 @@ import {
LocationRecord,
MentionRecord,
NotificationRecord,
PollRecord,
PollOptionRecord,
StatusEditRecord,
StatusRecord,
TagRecord,
@ -47,8 +45,6 @@ type List = ReturnType<typeof ListRecord>;
type Location = ReturnType<typeof LocationRecord>;
type Mention = ReturnType<typeof MentionRecord>;
type Notification = ReturnType<typeof NotificationRecord>;
type Poll = ReturnType<typeof PollRecord>;
type PollOption = ReturnType<typeof PollOptionRecord>;
type StatusEdit = ReturnType<typeof StatusEditRecord>;
type Tag = ReturnType<typeof TagRecord>;
@ -89,8 +85,6 @@ export {
Location,
Mention,
Notification,
Poll,
PollOption,
Status,
StatusEdit,
Tag,
@ -106,5 +100,7 @@ export type {
Group,
GroupMember,
GroupRelationship,
Poll,
PollOption,
Relationship,
} from 'soapbox/schemas';
Loading…
Cancel
Save