From 00a8fbc43ebedd8756932731f4e8c052ae651449 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 29 Jun 2026 16:26:22 +0200 Subject: [PATCH] Testing and types updates (#39657) --- .storybook/storybook.d.ts | 25 +++- app/javascript/entrypoints/public.tsx | 3 +- app/javascript/mastodon/api_types/accounts.ts | 2 +- app/javascript/mastodon/api_types/quotes.ts | 8 +- .../components/account/account.stories.tsx | 11 +- .../account_list_item.stories.tsx | 9 +- .../display_name/display_name.stories.tsx | 6 +- .../form_fields/emoji_text_field.tsx | 7 +- .../mastodon/components/mini_card/index.tsx | 4 +- .../mastodon/components/mini_card/list.tsx | 4 +- .../status/boost_button.stories.tsx | 4 +- .../components/status/boost_button.tsx | 5 +- .../components/status/content.stories.tsx | 4 +- .../status/handled_link.stories.tsx | 4 +- .../components/status/status.stories.tsx | 40 +++--- .../components/status_quoted.stories.tsx | 17 ++- .../mastodon/components/tags/tag.tsx | 7 +- .../announcement/announcement.stories.tsx | 7 +- .../annual_report/annual_report.stories.tsx | 20 +-- app/javascript/mastodon/models/account.ts | 4 +- .../mastodon/models/custom_emoji.ts | 2 +- .../mastodon/reducers/slices/profile_edit.ts | 7 +- app/javascript/mastodon/utils/objects.test.ts | 9 +- app/javascript/mastodon/utils/objects.ts | 61 ++++----- app/javascript/mastodon/utils/types.ts | 40 +----- app/javascript/testing/api.ts | 10 +- app/javascript/testing/factories.ts | 123 ++++++++++++++---- package.json | 1 + yarn.lock | 10 ++ 29 files changed, 263 insertions(+), 191 deletions(-) diff --git a/.storybook/storybook.d.ts b/.storybook/storybook.d.ts index 4d439124158..5f3d17486b7 100644 --- a/.storybook/storybook.d.ts +++ b/.storybook/storybook.d.ts @@ -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 + ? unknown + : PartialDeep; +}; 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 {}; diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 848e600895e..363ca0fec9a 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -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', ); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 85ee62ad075..1f5a8082982 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -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; diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index 2a5e8b4e45b..de69394b98e 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -19,11 +19,13 @@ interface ApiNestedQuoteJSON { quoted_status_id: string; } +export type ApiQuotedStatusJSON = Omit & { + quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; +}; + interface ApiQuoteAcceptedJSON { state: 'accepted'; - quoted_status: Omit & { - quote?: ApiNestedQuoteJSON | ApiQuoteEmptyJSON; - }; + quoted_status: ApiQuotedStatusJSON; } export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON; diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx index 0b1e9e29b3c..62c52ba8c48 100644 --- a/app/javascript/mastodon/components/account/account.stories.tsx +++ b/app/javascript/mastodon/components/account/account.stories.tsx @@ -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, }), }, diff --git a/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx b/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx index 62875b02f67..5b201b4cdf2 100644 --- a/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx +++ b/app/javascript/mastodon/components/account_list_item/account_list_item.stories.tsx @@ -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, }), }, diff --git a/app/javascript/mastodon/components/display_name/display_name.stories.tsx b/app/javascript/mastodon/components/display_name/display_name.stories.tsx index 6f1819a5574..f442bcc256d 100644 --- a/app/javascript/mastodon/components/display_name/display_name.stories.tsx +++ b/app/javascript/mastodon/components/display_name/display_name.stories.tsx @@ -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, }) diff --git a/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx b/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx index 874ec91f79d..0b76c3c156b 100644 --- a/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx +++ b/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx @@ -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; export const EmojiTextInputField: FC< - OmitUnion, EmojiInputProps> + Merge, EmojiInputProps> > = ({ onChange, value, @@ -72,7 +73,7 @@ export const EmojiTextInputField: FC< }; export const EmojiTextAreaField: FC< - OmitUnion, EmojiInputProps> + Merge, EmojiInputProps> > = ({ onChange, value, diff --git a/app/javascript/mastodon/components/mini_card/index.tsx b/app/javascript/mastodon/components/mini_card/index.tsx index 78e240b01e2..6d0272c6f04 100644 --- a/app/javascript/mastodon/components/mini_card/index.tsx +++ b/app/javascript/mastodon/components/mini_card/index.tsx @@ -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; diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx index c98f57c8636..28ced491163 100644 --- a/app/javascript/mastodon/components/mini_card/list.tsx +++ b/app/javascript/mastodon/components/mini_card/list.tsx @@ -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, MiniCardListProps> + Merge, MiniCardListProps> >(({ cards = [], className, children, ...props }, ref) => { if (!cards.length) { return null; diff --git a/app/javascript/mastodon/components/status/boost_button.stories.tsx b/app/javascript/mastodon/components/status/boost_button.stories.tsx index 402695a8295..5ea0040212e 100644 --- a/app/javascript/mastodon/components/status/boost_button.stories.tsx +++ b/app/javascript/mastodon/components/status/boost_button.stories.tsx @@ -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, diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx index c298c1c7440..d65018cdafd 100644 --- a/app/javascript/mastodon/components/status/boost_button.tsx +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -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; +type ActionMenuItemWithIcon = SetRequired; const BoostOrQuoteMenu: FC = ({ status, counters }) => { const intl = useIntl(); diff --git a/app/javascript/mastodon/components/status/content.stories.tsx b/app/javascript/mastodon/components/status/content.stories.tsx index cb9bb9d3ec9..93dc5fcc3a2 100644 --- a/app/javascript/mastodon/components/status/content.stories.tsx +++ b/app/javascript/mastodon/components/status/content.stories.tsx @@ -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}

(in ${args.translatedTo})

`, diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx index e3438337048..20475955cef 100644 --- a/app/javascript/mastodon/components/status/handled_link.stories.tsx +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -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' }), }, }, }, diff --git a/app/javascript/mastodon/components/status/status.stories.tsx b/app/javascript/mastodon/components/status/status.stories.tsx index a9244f56c94..6afabcc9016 100644 --- a/app/javascript/mastodon/components/status/status.stories.tsx +++ b/app/javascript/mastodon/components/status/status.stories.tsx @@ -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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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, diff --git a/app/javascript/mastodon/components/status_quoted.stories.tsx b/app/javascript/mastodon/components/status_quoted.stories.tsx index 5b78d3a3c57..60a93cbc846 100644 --- a/app/javascript/mastodon/components/status_quoted.stories.tsx +++ b/app/javascript/mastodon/components/status_quoted.stories.tsx @@ -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 diff --git a/app/javascript/mastodon/components/tags/tag.tsx b/app/javascript/mastodon/components/tags/tag.tsx index 81928543277..fb373a854b7 100644 --- a/app/javascript/mastodon/components/tags/tag.tsx +++ b/app/javascript/mastodon/components/tags/tag.tsx @@ -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, TagProps> + Merge, 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; diff --git a/app/javascript/mastodon/features/annual_report/announcement/announcement.stories.tsx b/app/javascript/mastodon/features/annual_report/announcement/announcement.stories.tsx index 1b96f6f60bb..0032602fc0a 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/announcement.stories.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/announcement.stories.tsx @@ -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 & { 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 = { diff --git a/app/javascript/mastodon/features/annual_report/annual_report.stories.tsx b/app/javascript/mastodon/features/annual_report/annual_report.stories.tsx index 3ccaceae6c5..0d145e076af 100644 --- a/app/javascript/mastodon/features/annual_report/annual_report.stories.tsx +++ b/app/javascript/mastodon/features/annual_report/annual_report.stories.tsx @@ -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, }), diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index ccb6f47621d..8a14674ce4a 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -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 { @@ -59,7 +59,7 @@ export type AccountShapeFull = Omit< AccountShape, 'emojis' | 'fields' | 'roles' > & { - emojis: CustomEmoji[]; + emojis: CustomEmojiShape[]; fields: AccountFieldShape[]; roles: AccountRoleShape[]; }; diff --git a/app/javascript/mastodon/models/custom_emoji.ts b/app/javascript/mastodon/models/custom_emoji.ts index 19ca951a5ca..0588de12823 100644 --- a/app/javascript/mastodon/models/custom_emoji.ts +++ b/app/javascript/mastodon/models/custom_emoji.ts @@ -3,7 +3,7 @@ import { Record as ImmutableRecord, isList } from 'immutable'; import type { ApiCustomEmojiJSON } from 'mastodon/api_types/custom_emoji'; -type CustomEmojiShape = Required; // no changes from server shape +export type CustomEmojiShape = Required; // no changes from server shape export type CustomEmoji = RecordOf; export const CustomEmojiFactory = ImmutableRecord({ diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index bc463ac1bdb..83963fe49a3 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -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]: ApiProfileJSON[Key]; + > as CamelCase]: ApiProfileJSON[Key]; } & { bio: ApiProfileJSON['note']; fields: FieldData[]; @@ -45,7 +46,7 @@ export type TagData = { [Key in keyof Omit< ApiFeaturedTagJSON, 'statuses_count' - > as SnakeToCamelCase]: ApiFeaturedTagJSON[Key]; + > as CamelCase]: ApiFeaturedTagJSON[Key]; } & { statusesCount: number; }; diff --git a/app/javascript/mastodon/utils/objects.test.ts b/app/javascript/mastodon/utils/objects.test.ts index 5c08cd05611..2b1852edc73 100644 --- a/app/javascript/mastodon/utils/objects.test.ts +++ b/app/javascript/mastodon/utils/objects.test.ts @@ -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 }); }); }); diff --git a/app/javascript/mastodon/utils/objects.ts b/app/javascript/mastodon/utils/objects.ts index 4430849c0c3..7eca2276431 100644 --- a/app/javascript/mastodon/utils/objects.ts +++ b/app/javascript/mastodon/utils/objects.ts @@ -1,52 +1,37 @@ import { isPlainObject } from '@reduxjs/toolkit'; -export type RecordObject = Record; +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 = K extends readonly [ - infer Head, - ...infer Tail, -] - ? Head extends keyof NonNullable - ? Tail extends readonly PropertyKey[] - ? NestedProperty[Head], Tail> - : NonNullable[Head] - : undefined - : T; +type NestedProperty = + IsUnknown extends true + ? unknown + : IsUnknown> extends true + ? undefined + : Get; +export function getNestedProperty(object: TObject): TObject; export function getNestedProperty< - TObject extends RecordObject, - const TKeys extends readonly PropertyKey[], ->(object: TObject, ...keys: TKeys): NestedProperty | 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; +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; } diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index c6922210b4a..4da43681008 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -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; - * type PersonWithSomeOptional = SomeOptional; - */ +import type { SetOptional } from 'type-fest'; -export type DeepPartial = T extends object - ? { - [K in keyof T]?: DeepPartial; - } - : T; - -export type SomeRequired = T & Required>; -export type SomeOptional = Pick> & - Partial>; - -export type RequiredExcept = SomeOptional, K>; - -export type OmitValueType = { - [K in keyof T as T[K] extends V ? never : K]: T[K]; -}; - -export type OmitUnion = TBase & Omit; - -export type PickValueType = { - [K in keyof T as T[K] extends V | undefined ? K : never]: T[K]; -}; +export type RequiredExcept = SetOptional, K>; export type AnyFunction = (...args: never) => unknown; - -export type SnakeToCamelCase = - S extends `${infer T}_${infer U}` - ? `${T}${Capitalize>}` - : S; diff --git a/app/javascript/testing/api.ts b/app/javascript/testing/api.ts index ad980a8da97..ec75098d290 100644 --- a/app/javascript/testing/api.ts +++ b/app/javascript/testing/api.ts @@ -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, }), diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index bcd61e3065e..77a7a719c99 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -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 = { id?: string; } & Partial; type FactoryFunction = (options?: FactoryOptions) => T; -export const accountFactory: FactoryFunction = ({ +export const accountFactoryAPI: FactoryFunction = ({ id, ...data } = {}) => ({ @@ -75,9 +90,35 @@ export const accountFactory: FactoryFunction = ({ export const accountFactoryState = ( options: FactoryOptions = {}, -) => 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 = ({ +export const accountFactoryImmutable = ( + options: FactoryOptions = {}, +) => createAccountFromServerJSON(accountFactoryAPI(options)); + +export const statusFactoryAPI: FactoryFunction = ({ id, ...data } = {}) => ({ @@ -92,7 +133,7 @@ export const statusFactory: FactoryFunction = ({ 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 = ({ export const statusFactoryState = ( options: FactoryOptions = {}, -) => fromJS(normalizeStatus(statusFactory(options))) as unknown as Status; +) => normalizeStatus(statusFactoryAPI(options)); + +export const statusFactoryImmutable = ( + options: FactoryOptions = {}, +) => fromJS(statusFactoryState(options)) as unknown as Status; // Convert to unknown to avoid excessive type recursion + +export const statusQuotedFactoryAPI: FactoryFunction = ( + 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 = Omit< - DeepPartial, + PartialDeep, 'type' >; -export const imageAttachmentFactory = ( +export const imageAttachmentFactoryAPI = ( data: MediaFactoryArg = {}, ): ApiImageAttachmentJSON => ({ ...baseAttachment, @@ -152,7 +211,7 @@ export const imageAttachmentFactory = ( }, }); -export const videoAttachmentFactory = ( +export const videoAttachmentFactoryAPI = ( data: MediaFactoryArg = {}, ): ApiVideoAttachmentJSON => ({ ...baseAttachment, @@ -170,7 +229,7 @@ export const videoAttachmentFactory = ( }, }); -export const audioAttachmentFactory = ( +export const audioAttachmentFactoryAPI = ( data: MediaFactoryArg = {}, ): ApiAudioAttachmentJSON => ({ ...baseAttachment, @@ -183,7 +242,7 @@ export const audioAttachmentFactory = ( }, }); -export const gifvAttachmentFactory = ( +export const gifvAttachmentFactoryAPI = ( data: MediaFactoryArg = {}, ): ApiGifvAttachmentJSON => ({ ...baseAttachment, @@ -195,24 +254,26 @@ export const gifvAttachmentFactory = ( }, }); -export function mediaAttachmentFactory( - data: DeepPartial = {}, +export function mediaAttachmentFactoryAPI( + data: PartialDeep = {}, ): ApiMediaAttachmentJSON { switch (data.type ?? 'image') { case 'image': - return imageAttachmentFactory( - data as DeepPartial, + return imageAttachmentFactoryAPI( + data as PartialDeep, ); case 'video': - return videoAttachmentFactory( - data as DeepPartial, + return videoAttachmentFactoryAPI( + data as PartialDeep, ); case 'audio': - return audioAttachmentFactory( - data as DeepPartial, + return audioAttachmentFactoryAPI( + data as PartialDeep, ); case 'gifv': - return gifvAttachmentFactory(data as DeepPartial); + return gifvAttachmentFactoryAPI( + data as PartialDeep, + ); default: { return { ...baseAttachment, @@ -224,7 +285,7 @@ export function mediaAttachmentFactory( } } -export const pollFactory: FactoryFunction = (data = {}) => ({ +export const pollFactoryAPI: FactoryFunction = (data = {}) => ({ id: '1', expires_at: '', expired: false, @@ -246,7 +307,21 @@ export const pollFactory: FactoryFunction = (data = {}) => ({ ...data, }); -export const relationshipsFactory: FactoryFunction = ({ +export const pollFactoryImmutable = ( + data: FactoryOptions = {}, +): 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 = ({ 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', diff --git a/package.json b/package.json index 26747fe9d97..9aadaf78f0b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 4722e355743..f37e4e89ae5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"