Testing and types updates (#39657)

This commit is contained in:
Echo 2026-06-29 16:26:22 +02:00 committed by GitHub
parent b2ebfa13a9
commit 00a8fbc43e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 263 additions and 191 deletions

View File

@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// The addon package.json incorrectly exports types, so we need to override them here.
import type { PartialDeep } from 'type-fest';
import type { RootState } from '@/mastodon/store';
// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76
@ -7,13 +10,22 @@ declare module '@storybook/addon-vitest/vitest-plugin' {
export * from '@storybook/addon-vitest/dist/vitest-plugin/index';
}
type RootPathKeys = keyof RootState;
type TypedRootState = {
[Key in keyof RootState]?: RootState[Key] extends Immutable.OrderedCollection<any>
? unknown
: PartialDeep<RootState[Key]>;
};
declare module 'storybook/internal/csf' {
export interface InputType {
/**
* Connects an argument value deeply in the Redux state.
*
* Can either be a period separated string or an array.
*/
reduxPath?:
| `${RootPathKeys}.${string}`
| [RootPathKeys, ...(string | number)[]];
| `${keyof TypedRootState}.${string}`
| [keyof TypedRootState, ...(string | number)[]];
}
export interface Globals {
@ -21,6 +33,13 @@ declare module 'storybook/internal/csf' {
theme: 'light' | 'dark';
loggedIn: 'true' | 'false';
}
export interface Parameters {
/** Provides the Redux state as a JS object for the component. */
state?: TypedRootState;
/** Callback that is run with the story arguments to generate Redux state for the component. */
stateFn?: (args: any) => TypedRootState;
}
}
export {};

View File

@ -15,6 +15,7 @@ import { throttle } from 'lodash';
import { determineEmojiMode } from '@/mastodon/features/emoji/mode';
import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render';
import type { InitialState } from '@/mastodon/initial_state';
import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '@/mastodon/locales';
import { loadPolyfills } from '@/mastodon/polyfills';
@ -83,7 +84,7 @@ async function loaded() {
document.getElementById('initial-state')?.textContent;
if (initialStateText) {
const stateEmojiStyle = getNestedProperty(
JSON.parse(initialStateText) as unknown,
JSON.parse(initialStateText) as InitialState,
'meta',
'emoji_style',
);

View File

@ -60,7 +60,7 @@ export interface BaseApiAccountJSON {
show_featured: boolean;
noindex?: boolean;
note: string;
roles?: ApiAccountJSON[];
roles?: ApiAccountRoleJSON[];
statuses_count: number;
uri: string;
url?: string;

View File

@ -19,11 +19,13 @@ interface ApiNestedQuoteJSON {
quoted_status_id: string;
}
export type ApiQuotedStatusJSON = Omit<ApiStatusJSON, 'quote'> & {
quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
};
interface ApiQuoteAcceptedJSON {
state: 'accepted';
quoted_status: Omit<ApiStatusJSON, 'quote'> & {
quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON;
};
quoted_status: ApiQuotedStatusJSON;
}
export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON;

View File

@ -2,7 +2,10 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
import {
accountFactoryImmutable,
relationshipsFactoryAPI,
} from '@/testing/factories';
import { Account } from './index';
@ -69,7 +72,7 @@ const meta = {
parameters: {
state: {
accounts: {
'1': accountFactoryState(),
'1': accountFactoryImmutable(),
},
},
},
@ -121,7 +124,7 @@ export const Blocked: Story = {
parameters: {
state: {
relationships: {
'1': relationshipsFactory({
'1': relationshipsFactoryAPI({
blocking: true,
}),
},
@ -134,7 +137,7 @@ export const Muted: Story = {
parameters: {
state: {
relationships: {
'1': relationshipsFactory({
'1': relationshipsFactoryAPI({
muting: true,
}),
},

View File

@ -1,6 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
import {
accountFactoryImmutable,
relationshipsFactoryAPI,
} from '@/testing/factories';
import { PendingBadge } from '../badge';
@ -16,7 +19,7 @@ const meta = {
parameters: {
state: {
accounts: {
'1': accountFactoryState(),
'1': accountFactoryImmutable(),
},
},
},
@ -32,7 +35,7 @@ export const FollowsYou: Story = {
parameters: {
state: {
relationships: {
'1': relationshipsFactory({
'1': relationshipsFactoryAPI({
followed_by: true,
}),
},

View File

@ -2,7 +2,7 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState } from '@/testing/factories';
import { accountFactoryImmutable } from '@/testing/factories';
import { DisplayName, LinkedDisplayName } from './index';
@ -23,7 +23,7 @@ const meta = {
tags: [],
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
? accountFactoryImmutable({
display_name: name,
acct: username,
})
@ -69,7 +69,7 @@ export const LocalUser: Story = {
export const Linked: Story = {
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
? accountFactoryImmutable({
display_name: name,
acct: username,
})

View File

@ -8,8 +8,9 @@ import type {
} from 'react';
import { useCallback, useId, useRef } from 'react';
import type { Merge } from 'type-fest';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { OmitUnion } from '@/mastodon/utils/types';
import { CharacterCounter } from '../character_counter';
import { EmojiPickerButton } from '../emoji/picker_button';
@ -29,7 +30,7 @@ export type EmojiInputProps = {
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
export const EmojiTextInputField: FC<
OmitUnion<ComponentPropsWithoutRef<'input'>, EmojiInputProps>
Merge<ComponentPropsWithoutRef<'input'>, EmojiInputProps>
> = ({
onChange,
value,
@ -72,7 +73,7 @@ export const EmojiTextInputField: FC<
};
export const EmojiTextAreaField: FC<
OmitUnion<Omit<TextAreaProps, 'style'>, EmojiInputProps>
Merge<Omit<TextAreaProps, 'style'>, EmojiInputProps>
> = ({
onChange,
value,

View File

@ -3,14 +3,14 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import classNames from 'classnames';
import type { OmitUnion } from '@/mastodon/utils/types';
import type { Merge } from 'type-fest';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import classes from './styles.module.css';
export type MiniCardProps = OmitUnion<
export type MiniCardProps = Merge<
ComponentPropsWithoutRef<'div'>,
{
label: ReactNode;

View File

@ -3,7 +3,7 @@ import type { ComponentPropsWithoutRef, Key } from 'react';
import classNames from 'classnames';
import type { OmitUnion } from '@/mastodon/utils/types';
import type { Merge } from 'type-fest';
import { MiniCard } from '.';
import type { MiniCardProps as BaseCardProps } from '.';
@ -19,7 +19,7 @@ interface MiniCardListProps {
export const MiniCardList = forwardRef<
HTMLDListElement,
OmitUnion<ComponentPropsWithoutRef<'dl'>, MiniCardListProps>
Merge<ComponentPropsWithoutRef<'dl'>, MiniCardListProps>
>(({ cards = [], className, children, ...props }, ref) => {
if (!cards.length) {
return null;

View File

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { statusFactoryState } from '@/testing/factories';
import { statusFactoryImmutable } from '@/testing/factories';
import { BoostButton } from './boost_button';
@ -50,7 +50,7 @@ function argsToStatus({
quoteAllowed,
alreadyBoosted,
}: StoryProps) {
return statusFactoryState({
return statusFactoryImmutable({
reblogs_count: reblogCount,
visibility,
reblogged: alreadyBoosted,

View File

@ -5,6 +5,8 @@ import { useIntl } from 'react-intl';
import classNames from 'classnames';
import type { SetRequired } from 'type-fest';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
@ -13,7 +15,6 @@ import { quickBoosting } from '@/mastodon/initial_state';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import type { SomeRequired } from '@/mastodon/utils/types';
import type { RenderItemFn } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
@ -91,7 +92,7 @@ interface ReblogButtonProps {
counters?: boolean;
}
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
type ActionMenuItemWithIcon = SetRequired<ActionMenuItem, 'icon'>;
const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
const intl = useIntl();

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { StatusTranslation } from '@/mastodon/models/status';
import { statusFactoryState } from '@/testing/factories';
import { statusFactoryImmutable } from '@/testing/factories';
import { StatusContent } from './content';
@ -57,7 +57,7 @@ const meta = {
},
},
stateFn(args: StatusContentProps) {
let status = statusFactoryState();
let status = statusFactoryImmutable();
if (args.translatedTo) {
status = status.set('translation', {
contentHtml: `${args.text}<p><em>(in ${args.translatedTo})</em></p>`,

View File

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller';
import { accountFactoryState } from '@/testing/factories';
import { accountFactoryImmutable } from '@/testing/factories';
import { HoverCardController } from '../hover_card_controller';
@ -55,7 +55,7 @@ const meta = {
parameters: {
state: {
accounts: {
'1': accountFactoryState({ id: '1', acct: 'hashtaguser' }),
'1': accountFactoryImmutable({ id: '1', acct: 'hashtaguser' }),
},
},
},

View File

@ -9,11 +9,11 @@ import { fn } from 'storybook/test';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import {
accountFactoryState,
mediaAttachmentFactory,
pollFactory,
statusFactory,
statusFactoryState,
accountFactoryImmutable,
mediaAttachmentFactoryAPI,
pollFactoryImmutable,
statusFactoryAPI,
statusFactoryImmutable,
} from '@/testing/factories';
import { TypedStatus } from './types';
@ -71,7 +71,7 @@ interface StatusStoryProps {
showPrepend?: boolean;
}
const otherAccount = accountFactoryState({
const otherAccount = accountFactoryImmutable({
id: '2',
display_name: 'Another user',
});
@ -106,14 +106,14 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
showPrepend = true,
} = props;
const { account, status } = useMemo(() => {
const account = accountFactoryState();
const account = accountFactoryImmutable();
const media_attachments: ApiMediaAttachmentJSON[] = [];
switch (attachments) {
// Use fall through add attachments depending on count.
case 'image-3':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
id: '2',
url: 'https://cataas.com/cat/EbVq9zMc4Xxv7s73',
meta: {
@ -129,7 +129,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
// eslint-disable-next-line no-fallthrough
case 'image-2':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
id: '3',
url: 'https://cataas.com/cat/YFaQ4xWYoWURSz37',
meta: {
@ -145,7 +145,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
// eslint-disable-next-line no-fallthrough
case 'image-1':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
id: '4',
url: 'https://cataas.com/cat/bYBTjiFUqjUPIBUD',
meta: {
@ -161,7 +161,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
break;
case 'video':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
type: 'video',
url: 'https://www.pexels.com/download/video/11760787/',
meta: {
@ -175,7 +175,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
break;
case 'audio':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
type: 'audio',
url: 'https://upload.wikimedia.org/wikipedia/commons/4/40/Elephant_voice_-_trumpeting.ogg',
}),
@ -183,7 +183,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
break;
case 'gifv':
media_attachments.push(
mediaAttachmentFactory({
mediaAttachmentFactoryAPI({
type: 'gifv',
url: 'https://www.pexels.com/download/video/11760787/',
meta: {
@ -196,13 +196,15 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
);
break;
case 'unknown':
media_attachments.push(mediaAttachmentFactory({ type: attachments }));
media_attachments.push(
mediaAttachmentFactoryAPI({ type: attachments }),
);
break;
}
return {
account,
status: statusFactoryState({
status: statusFactoryImmutable({
text,
spoiler_text: contentWarning,
visibility,
@ -215,7 +217,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
quote: isQuote
? {
state: 'accepted',
quoted_status: { ...statusFactory(), quote: undefined },
quoted_status: { ...statusFactoryAPI(), quote: undefined },
}
: undefined,
favourites_count: favouriteCount,
@ -236,7 +238,7 @@ const StatusStoryComponent: FC<StatusStoryProps> = (props) => {
if (isReblog) {
status.set(
'reblog',
statusFactoryState({ id: '2' }).set('account', otherAccount),
statusFactoryImmutable({ id: '2' }).set('account', otherAccount),
);
}
if (isPoll) {
@ -469,8 +471,8 @@ const meta = {
'2': otherAccount,
},
polls: {
'1': pollFactory(),
'2': pollFactory({
'1': pollFactoryImmutable(),
'2': pollFactoryImmutable({
voted: true,
voters_count: 1,
votes_count: 1,

View File

@ -3,7 +3,10 @@ import { Map as ImmutableMap } from 'immutable';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ApiQuoteJSON } from '@/mastodon/api_types/quotes';
import { accountFactoryState, statusFactoryState } from '@/testing/factories';
import {
accountFactoryImmutable,
statusFactoryImmutable,
} from '@/testing/factories';
import type { StatusQuoteManagerProps } from './status_quoted';
import { StatusQuoteManager } from './status_quoted';
@ -16,15 +19,15 @@ const meta = {
parameters: {
state: {
accounts: {
'1': accountFactoryState({ id: '1', acct: 'hashtaguser' }),
'1': accountFactoryImmutable({ id: '1', acct: 'hashtaguser' }),
},
statuses: {
'1': statusFactoryState({
'1': statusFactoryImmutable({
id: '1',
language: 'en',
text: 'Hello world!',
}),
'2': statusFactoryState({
'2': statusFactoryImmutable({
id: '2',
language: 'en',
text: 'Quote!',
@ -33,19 +36,19 @@ const meta = {
quoted_status: '1',
}) as unknown as ApiQuoteJSON,
}),
'1001': statusFactoryState({
'1001': statusFactoryImmutable({
id: '1001',
language: 'mn-Mong',
// meaning: Mongolia
text: 'ᠮᠤᠩᠭᠤᠯ',
}),
'1002': statusFactoryState({
'1002': statusFactoryImmutable({
id: '1002',
language: 'mn-Mong',
// meaning: All human beings are born free and equal in dignity and rights.
text: 'ᠬᠦᠮᠦᠨ ᠪᠦᠷ ᠲᠥᠷᠥᠵᠦ ᠮᠡᠨᠳᠡᠯᠡᠬᠦ ᠡᠷᠬᠡ ᠴᠢᠯᠥᠭᠡ ᠲᠡᠢ᠂ ᠠᠳᠠᠯᠢᠬᠠᠨ ᠨᠡᠷ᠎ᠡ ᠲᠥᠷᠥ ᠲᠡᠢ᠂ ᠢᠵᠢᠯ ᠡᠷᠬᠡ ᠲᠡᠢ ᠪᠠᠢᠠᠭ᠃',
}),
'1003': statusFactoryState({
'1003': statusFactoryImmutable({
id: '1003',
language: 'mn-Mong',
// meaning: Mongolia

View File

@ -5,7 +5,8 @@ import { useIntl } from 'react-intl';
import classNames from 'classnames';
import type { OmitUnion } from '@/mastodon/utils/types';
import type { Merge } from 'type-fest';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import type { IconProp } from '../icon';
@ -24,7 +25,7 @@ export interface TagProps {
export const Tag = forwardRef<
HTMLButtonElement,
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
Merge<ComponentPropsWithoutRef<'button'>, TagProps>
>(({ name, active, icon, className, children, ...props }, ref) => {
if (!name) {
return null;
@ -47,7 +48,7 @@ Tag.displayName = 'Tag';
export const EditableTag = forwardRef<
HTMLSpanElement,
OmitUnion<
Merge<
ComponentPropsWithoutRef<'span'>,
TagProps & {
onRemove: () => void;

View File

@ -1,17 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import type { ConditionalExcept } from 'type-fest';
import type { AnyFunction, OmitValueType } from '@/mastodon/utils/types';
import type { AnyFunction } from '@/mastodon/utils/types';
import type { AnnualReportAnnouncementProps } from '.';
import { AnnualReportAnnouncement } from '.';
type Props = OmitValueType<
type Props = ConditionalExcept<
// We can't use the name 'state' here because it's reserved for overriding Redux state.
Omit<AnnualReportAnnouncementProps, 'state'> & {
reportState: AnnualReportAnnouncementProps['state'];
},
AnyFunction // Remove any functions, as they can't meaningfully be controlled in Storybook.
AnyFunction | undefined // Remove any functions, as they can't meaningfully be controlled in Storybook.
>;
const meta = {

View File

@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
accountFactoryState,
annualReportFactory,
statusFactoryState,
accountFactoryImmutable,
annualReportFactoryState,
statusFactoryImmutable,
} from '@/testing/factories';
import { AnnualReport } from '.';
@ -22,12 +22,12 @@ const meta = {
parameters: {
state: {
accounts: {
'1': accountFactoryState({ display_name: 'Freddie Fruitbat' }),
'1': accountFactoryImmutable({ display_name: 'Freddie Fruitbat' }),
},
statuses: {
'1': statusFactoryState(),
'1': statusFactoryImmutable(),
},
annualReport: annualReportFactory({
annualReport: annualReportFactoryState({
top_hashtag: SAMPLE_HASHTAG,
}),
},
@ -54,7 +54,7 @@ export const ArchetypeOracle: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
annualReport: annualReportFactoryState({
archetype: 'oracle',
top_hashtag: SAMPLE_HASHTAG,
}),
@ -66,7 +66,7 @@ export const NoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
annualReport: annualReportFactoryState({
archetype: 'booster',
}),
},
@ -77,7 +77,7 @@ export const NoNewPosts: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
annualReport: annualReportFactoryState({
archetype: 'pollster',
top_hashtag: SAMPLE_HASHTAG,
without_posts: true,
@ -90,7 +90,7 @@ export const NoNewPostsNoHashtag: Story = {
...InModal,
parameters: {
state: {
annualReport: annualReportFactory({
annualReport: annualReportFactoryState({
archetype: 'replier',
without_posts: true,
}),

View File

@ -11,7 +11,7 @@ import type {
import { unescapeHTML } from 'mastodon/utils/html';
import { CustomEmojiFactory } from './custom_emoji';
import type { CustomEmoji } from './custom_emoji';
import type { CustomEmoji, CustomEmojiShape } from './custom_emoji';
// AccountField
export interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
@ -59,7 +59,7 @@ export type AccountShapeFull = Omit<
AccountShape,
'emojis' | 'fields' | 'roles'
> & {
emojis: CustomEmoji[];
emojis: CustomEmojiShape[];
fields: AccountFieldShape[];
roles: AccountRoleShape[];
};

View File

@ -3,7 +3,7 @@ import { Record as ImmutableRecord, isList } from 'immutable';
import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji';
type CustomEmojiShape = Required<ApiCustomEmojiJSON>; // no changes from server shape
export type CustomEmojiShape = Required<ApiCustomEmojiJSON>; // no changes from server shape
export type CustomEmoji = RecordOf<CustomEmojiShape>;
export const CustomEmojiFactory = ImmutableRecord<CustomEmojiShape>({

View File

@ -1,5 +1,7 @@
import { createSlice } from '@reduxjs/toolkit';
import type { CamelCase } from 'type-fest';
import { fetchAccount } from '@/mastodon/actions/accounts';
import {
apiDeleteFeaturedTag,
@ -26,13 +28,12 @@ import {
createDataLoadingThunk,
} from '@/mastodon/store/typed_functions';
import { hashObjectArray } from '@/mastodon/utils/hash';
import type { SnakeToCamelCase } from '@/mastodon/utils/types';
type ProfileData = {
[Key in keyof Omit<
ApiProfileJSON,
'note' | 'fields' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
> as CamelCase<Key>]: ApiProfileJSON[Key];
} & {
bio: ApiProfileJSON['note'];
fields: FieldData[];
@ -45,7 +46,7 @@ export type TagData = {
[Key in keyof Omit<
ApiFeaturedTagJSON,
'statuses_count'
> as SnakeToCamelCase<Key>]: ApiFeaturedTagJSON[Key];
> as CamelCase<Key>]: ApiFeaturedTagJSON[Key];
} & {
statusesCount: number;
};

View File

@ -2,13 +2,13 @@
import { getNestedProperty } from './objects';
describe('getNestedProperty', () => {
const obj = { a: { b: { c: 42 } } } as const;
test('returns the value of a nested property if it exists', () => {
const obj = { a: { b: { c: 42 } } };
expect(getNestedProperty(obj, 'a', 'b', 'c')).toBe(42);
});
test('returns undefined if any part of the path does not exist', () => {
const obj = { a: { b: { c: 42 } } };
expect(getNestedProperty(obj, 'a', 'x', 'c')).toBeUndefined();
expect(getNestedProperty(obj, 'a', 'b', 'x')).toBeUndefined();
expect(getNestedProperty(obj, 'x', 'b', 'c')).toBeUndefined();
@ -20,8 +20,7 @@ describe('getNestedProperty', () => {
expect(getNestedProperty('string', 'a', 'b')).toBeUndefined();
});
test('returns undefined if no keys are provided', () => {
const obj = { a: 1 };
expect(getNestedProperty(obj)).toBeUndefined();
test('returns the object if no keys are provided', () => {
expect(getNestedProperty({ a: 1 })).toStrictEqual({ a: 1 });
});
});

View File

@ -1,52 +1,37 @@
import { isPlainObject } from '@reduxjs/toolkit';
export type RecordObject = Record<PropertyKey, unknown>;
import type { Get, IsUnknown, UnknownRecord } from 'type-fest';
export function isRecordObject(obj: unknown): obj is RecordObject {
export function isRecordObject(obj: unknown): obj is UnknownRecord {
return isPlainObject(obj);
}
type NestedProperty<T, K extends readonly PropertyKey[]> = K extends readonly [
infer Head,
...infer Tail,
]
? Head extends keyof NonNullable<T>
? Tail extends readonly PropertyKey[]
? NestedProperty<NonNullable<T>[Head], Tail>
: NonNullable<T>[Head]
: undefined
: T;
type NestedProperty<TObject, TKeys extends readonly string[]> =
IsUnknown<TObject> extends true
? unknown
: IsUnknown<Get<TObject, TKeys>> extends true
? undefined
: Get<TObject, TKeys>;
export function getNestedProperty<TObject>(object: TObject): TObject;
export function getNestedProperty<
TObject extends RecordObject,
const TKeys extends readonly PropertyKey[],
>(object: TObject, ...keys: TKeys): NestedProperty<TObject, TKeys> | undefined;
export function getNestedProperty(
object: unknown,
...keys: PropertyKey[]
): unknown;
export function getNestedProperty(
object: unknown,
...keys: PropertyKey[]
): unknown {
if (!isRecordObject(object) || keys.length === 0) {
return undefined;
TObject,
const TKeys extends readonly string[],
>(object: TObject, ...keys: TKeys): NestedProperty<TObject, TKeys>;
export function getNestedProperty(object: unknown, ...keys: readonly string[]) {
if (keys.length === 0) {
return object;
}
const remainingKeys = [...keys];
let currentObject: RecordObject = object;
while (remainingKeys.length > 0) {
const currentKey = remainingKeys.shift();
if (currentKey !== undefined && currentKey in currentObject) {
const nextObject = currentObject[currentKey];
if (isRecordObject(nextObject)) {
currentObject = nextObject;
continue;
} else if (remainingKeys.length === 0) {
return nextObject;
}
let currentValue = object;
for (const key of keys) {
if (!isRecordObject(currentValue) || !(key in currentValue)) {
return undefined;
}
currentValue = currentValue[key];
}
return undefined;
return currentValue;
}

View File

@ -1,41 +1,5 @@
/**
* Extend an existing type and make some of its properties required or optional.
* @example
* interface Person {
* name: string;
* age?: number;
* likesIceCream?: boolean;
* }
*
* type PersonWithSomeRequired = SomeRequired<Person, 'age' | 'likesIceCream' >;
* type PersonWithSomeOptional = SomeOptional<Person, 'name' >;
*/
import type { SetOptional } from 'type-fest';
export type DeepPartial<T> = T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>;
export type RequiredExcept<T, K extends keyof T> = SomeOptional<Required<T>, K>;
export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
export type PickValueType<T, V> = {
[K in keyof T as T[K] extends V | undefined ? K : never]: T[K];
};
export type RequiredExcept<T, K extends keyof T> = SetOptional<Required<T>, K>;
export type AnyFunction = (...args: never) => unknown;
export type SnakeToCamelCase<S extends string> =
S extends `${infer T}_${infer U}`
? `${T}${Capitalize<SnakeToCamelCase<U>>}`
: S;

View File

@ -4,13 +4,13 @@ import { action } from 'storybook/actions';
import { toSupportedLocale } from '@/mastodon/features/emoji/locale';
import { customEmojiFactory, relationshipsFactory } from './factories';
import { customEmojiFactory, relationshipsFactoryAPI } from './factories';
export const mockHandlers = {
mute: http.post<{ id: string }>('/api/v1/accounts/:id/mute', ({ params }) => {
action('muting account')(params);
return HttpResponse.json(
relationshipsFactory({ id: params.id, muting: true }),
relationshipsFactoryAPI({ id: params.id, muting: true }),
);
}),
unmute: http.post<{ id: string }>(
@ -18,7 +18,7 @@ export const mockHandlers = {
({ params }) => {
action('unmuting account')(params);
return HttpResponse.json(
relationshipsFactory({ id: params.id, muting: false }),
relationshipsFactoryAPI({ id: params.id, muting: false }),
);
},
),
@ -27,7 +27,7 @@ export const mockHandlers = {
({ params }) => {
action('blocking account')(params);
return HttpResponse.json(
relationshipsFactory({ id: params.id, blocking: true }),
relationshipsFactoryAPI({ id: params.id, blocking: true }),
);
},
),
@ -36,7 +36,7 @@ export const mockHandlers = {
({ params }) => {
action('unblocking account')(params);
return HttpResponse.json(
relationshipsFactory({
relationshipsFactoryAPI({
id: params.id,
blocking: false,
}),

View File

@ -1,5 +1,7 @@
import { fromJS } from 'immutable';
import type { PartialDeep } from 'type-fest';
import { normalizeStatus } from '@/mastodon/actions/importer/statuses';
import type {
ApiAudioAttachmentJSON,
@ -10,25 +12,38 @@ import type {
BaseApiMediaAttachmentJSON,
} from '@/mastodon/api_types/media_attachments';
import type { ApiPollJSON } from '@/mastodon/api_types/polls';
import type { ApiQuotedStatusJSON } from '@/mastodon/api_types/quotes';
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
import type { ApiStatusJSON } from '@/mastodon/api_types/statuses';
import type {
CustomEmojiData,
UnicodeEmojiData,
} from '@/mastodon/features/emoji/types';
import { createAccountFromServerJSON } from '@/mastodon/models/account';
import type { AccountShapeFull } from '@/mastodon/models/account';
import {
accountDefaultValues,
createAccountFromServerJSON,
} from '@/mastodon/models/account';
import type { AnnualReport } from '@/mastodon/models/annual_report';
import { CustomEmojiFactory } from '@/mastodon/models/custom_emoji';
import type { Poll } from '@/mastodon/models/poll';
import type { Status } from '@/mastodon/models/status';
import type { DeepPartial } from '@/mastodon/utils/types';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
/**
* Naming conventions for factories:
* - API responses should be `*FactoryAPI`
* - Plain JS objects in state should be `*FactoryState`
* - Immutable factories should be `*FactoryImmutable`
*/
type FactoryOptions<T> = {
id?: string;
} & Partial<T>;
type FactoryFunction<T> = (options?: FactoryOptions<T>) => T;
export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
export const accountFactoryAPI: FactoryFunction<ApiAccountJSON> = ({
id,
...data
} = {}) => ({
@ -75,9 +90,35 @@ export const accountFactory: FactoryFunction<ApiAccountJSON> = ({
export const accountFactoryState = (
options: FactoryOptions<ApiAccountJSON> = {},
) => createAccountFromServerJSON(accountFactory(options));
): AccountShapeFull => {
const accountJSON = accountFactoryAPI(options);
return {
...accountJSON,
...accountDefaultValues,
moved: accountJSON.moved?.id ?? null,
display_name_html: accountJSON.display_name,
note_emojified: accountJSON.note,
note_plain: accountJSON.note,
emojis: accountJSON.emojis.map((emoji) => ({
category: '',
featured: false,
...emoji,
})),
fields: accountJSON.fields.map((field) => ({
name_emojified: field.name,
value_emojified: field.value,
value_plain: field.value,
...field,
})),
roles: accountJSON.roles ?? [],
};
};
export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
export const accountFactoryImmutable = (
options: FactoryOptions<ApiAccountJSON> = {},
) => createAccountFromServerJSON(accountFactoryAPI(options));
export const statusFactoryAPI: FactoryFunction<ApiStatusJSON> = ({
id,
...data
} = {}) => ({
@ -92,7 +133,7 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
reblogs_count: 0,
quotes_count: 0,
favourites_count: 0,
account: accountFactory(),
account: accountFactoryAPI(),
media_attachments: [],
mentions: [],
tags: [],
@ -108,7 +149,25 @@ export const statusFactory: FactoryFunction<ApiStatusJSON> = ({
export const statusFactoryState = (
options: FactoryOptions<ApiStatusJSON> = {},
) => fromJS(normalizeStatus(statusFactory(options))) as unknown as Status;
) => normalizeStatus(statusFactoryAPI(options));
export const statusFactoryImmutable = (
options: FactoryOptions<ApiStatusJSON> = {},
) => fromJS(statusFactoryState(options)) as unknown as Status; // Convert to unknown to avoid excessive type recursion
export const statusQuotedFactoryAPI: FactoryFunction<ApiQuotedStatusJSON> = (
options = {},
) => {
const { quote, ...status } = options;
return {
...statusFactoryAPI(status),
quote: quote
? {
...quote,
}
: undefined,
};
};
const baseAttachment = {
id: '1',
@ -136,11 +195,11 @@ const colorsMeta = {
} as const;
type MediaFactoryArg<T extends BaseApiMediaAttachmentJSON> = Omit<
DeepPartial<T>,
PartialDeep<T>,
'type'
>;
export const imageAttachmentFactory = (
export const imageAttachmentFactoryAPI = (
data: MediaFactoryArg<ApiImageAttachmentJSON> = {},
): ApiImageAttachmentJSON => ({
...baseAttachment,
@ -152,7 +211,7 @@ export const imageAttachmentFactory = (
},
});
export const videoAttachmentFactory = (
export const videoAttachmentFactoryAPI = (
data: MediaFactoryArg<ApiVideoAttachmentJSON> = {},
): ApiVideoAttachmentJSON => ({
...baseAttachment,
@ -170,7 +229,7 @@ export const videoAttachmentFactory = (
},
});
export const audioAttachmentFactory = (
export const audioAttachmentFactoryAPI = (
data: MediaFactoryArg<ApiAudioAttachmentJSON> = {},
): ApiAudioAttachmentJSON => ({
...baseAttachment,
@ -183,7 +242,7 @@ export const audioAttachmentFactory = (
},
});
export const gifvAttachmentFactory = (
export const gifvAttachmentFactoryAPI = (
data: MediaFactoryArg<ApiGifvAttachmentJSON> = {},
): ApiGifvAttachmentJSON => ({
...baseAttachment,
@ -195,24 +254,26 @@ export const gifvAttachmentFactory = (
},
});
export function mediaAttachmentFactory(
data: DeepPartial<ApiMediaAttachmentJSON> = {},
export function mediaAttachmentFactoryAPI(
data: PartialDeep<ApiMediaAttachmentJSON> = {},
): ApiMediaAttachmentJSON {
switch (data.type ?? 'image') {
case 'image':
return imageAttachmentFactory(
data as DeepPartial<ApiImageAttachmentJSON>,
return imageAttachmentFactoryAPI(
data as PartialDeep<ApiImageAttachmentJSON>,
);
case 'video':
return videoAttachmentFactory(
data as DeepPartial<ApiVideoAttachmentJSON>,
return videoAttachmentFactoryAPI(
data as PartialDeep<ApiVideoAttachmentJSON>,
);
case 'audio':
return audioAttachmentFactory(
data as DeepPartial<ApiAudioAttachmentJSON>,
return audioAttachmentFactoryAPI(
data as PartialDeep<ApiAudioAttachmentJSON>,
);
case 'gifv':
return gifvAttachmentFactory(data as DeepPartial<ApiGifvAttachmentJSON>);
return gifvAttachmentFactoryAPI(
data as PartialDeep<ApiGifvAttachmentJSON>,
);
default: {
return {
...baseAttachment,
@ -224,7 +285,7 @@ export function mediaAttachmentFactory(
}
}
export const pollFactory: FactoryFunction<ApiPollJSON> = (data = {}) => ({
export const pollFactoryAPI: FactoryFunction<ApiPollJSON> = (data = {}) => ({
id: '1',
expires_at: '',
expired: false,
@ -246,7 +307,21 @@ export const pollFactory: FactoryFunction<ApiPollJSON> = (data = {}) => ({
...data,
});
export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
export const pollFactoryImmutable = (
data: FactoryOptions<ApiPollJSON> = {},
): Poll => ({
...pollFactoryAPI(data),
emojis: data.emojis?.map(CustomEmojiFactory) ?? [],
options:
data.options?.map((option) => ({
voted: false,
titleHtml: option.title,
translation: null,
...option,
})) ?? [],
});
export const relationshipsFactoryAPI: FactoryFunction<ApiRelationshipJSON> = ({
id,
...data
} = {}) => ({
@ -311,7 +386,7 @@ interface AnnualReportFactoryOptions {
without_posts?: boolean;
}
export function annualReportFactory({
export function annualReportFactoryState({
account_id = '1',
status_id = '1',
archetype = 'lurker',

View File

@ -179,6 +179,7 @@
"storybook": "^10.3.0",
"stylelint": "^17.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"type-fest": "^5.7.0",
"typescript": "~6.0.0",
"typescript-eslint": "^8.55.0",
"typescript-plugin-css-modules": "^5.2.0",

View File

@ -3037,6 +3037,7 @@ __metadata:
tesseract.js: "npm:^7.0.0"
tiny-queue: "npm:^0.2.1"
twitter-text: "npm:3.1.0"
type-fest: "npm:^5.7.0"
typescript: "npm:~6.0.0"
typescript-eslint: "npm:^8.55.0"
typescript-plugin-css-modules: "npm:^5.2.0"
@ -14124,6 +14125,15 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^5.7.0":
version: 5.7.0
resolution: "type-fest@npm:5.7.0"
dependencies:
tagged-tag: "npm:^1.0.0"
checksum: 10c0/f71ed17b753649421e419db8cc2e140f930333a1467b1d9cca2e0e4052900fd442f2360bae73f3a6bf9340d949ac46d9a1598c709b4c8089272e7624df9c8716
languageName: node
linkType: hard
"type-is@npm:^2.0.1":
version: 2.0.1
resolution: "type-is@npm:2.0.1"