diff --git a/dotcom-rendering/fixtures/manual/appsNav/au.ts b/dotcom-rendering/fixtures/manual/appsNav/au.ts new file mode 100644 index 00000000000..1687349b662 --- /dev/null +++ b/dotcom-rendering/fixtures/manual/appsNav/au.ts @@ -0,0 +1,371 @@ +export const auNav = { + pillars: [ + { + title: 'Australia', + path: 'australia-news', + sections: [], + }, + { + title: 'World', + path: 'world', + sections: [ + { + title: 'UK', + path: 'uk-news', + }, + { + title: 'US', + path: 'us-news', + }, + { + title: 'Asia', + path: 'world/asia', + }, + { + title: 'Europe', + path: 'world/europe-news', + }, + { + title: 'Americas', + path: 'world/americas', + }, + { + title: 'Africa', + path: 'world/africa', + }, + { + title: 'Middle East', + path: 'world/middleeast', + }, + { + title: 'Ukraine', + path: 'world/ukraine', + }, + ], + }, + { + title: 'Opinion', + path: 'au/commentisfree', + sections: [], + }, + { + title: 'Politics', + path: 'australia-news/australian-politics', + sections: [], + }, + { + title: 'Sport', + path: 'au/sport', + sections: [ + { + title: 'Australia sport', + path: 'sport/australia-sport', + }, + { + title: 'AFL', + path: 'sport/afl', + }, + { + title: 'NRL', + path: 'sport/nrl', + }, + { + title: 'A-League', + path: 'football/a-league', + }, + { + title: 'Football', + path: 'football', + mobileOverride: 'section-list', + }, + { + title: 'Cricket', + path: 'sport/cricket', + }, + { + title: 'Rugby union', + path: 'sport/rugby-union', + }, + { + title: 'Tennis', + path: 'sport/tennis', + }, + { + title: 'Cycling', + path: 'sport/cycling', + }, + { + title: 'Boxing', + path: 'sport/boxing', + }, + ], + }, + { + title: 'Football', + path: 'football', + sections: [], + }, + { + title: 'Tech', + path: 'au/technology', + sections: [], + }, + { + title: 'Culture', + path: 'au/culture', + sections: [ + { + title: 'Film', + path: 'au/film', + }, + { + title: 'Music', + path: 'music', + }, + { + title: 'Games', + path: 'games', + }, + { + title: 'Books', + path: 'books', + }, + { + title: 'TV & radio', + path: 'tv-and-radio', + }, + { + title: 'Art & design', + path: 'artanddesign', + }, + { + title: 'Stage', + path: 'stage', + }, + { + title: 'Classical', + path: 'music/classical-music-and-opera', + }, + ], + }, + { + title: 'Lifestyle', + path: 'au/lifeandstyle', + sections: [ + { + title: 'Food', + path: 'au/food', + }, + { + title: 'Relationships', + path: 'lifeandstyle/relationships', + }, + { + title: 'Fashion', + path: 'lifeandstyle/fashion', + }, + { + title: 'Beauty', + path: 'fashion/beauty', + }, + { + title: 'Cars', + path: 'technology/motoring', + }, + { + title: 'Health & fitness', + path: 'lifeandstyle/health-and-wellbeing', + }, + { + title: 'Women', + path: 'lifeandstyle/women', + }, + ], + }, + { + title: 'Fashion', + path: 'fashion', + sections: [], + }, + { + title: 'Economy', + path: 'au/business', + sections: [ + { + title: 'Markets', + path: 'business/stock-markets', + }, + { + title: 'Money', + path: 'money/money', + }, + ], + }, + { + title: 'Travel', + path: 'travel', + sections: [ + { + title: 'Australasia', + path: 'travel/australasia', + }, + { + title: 'Asia', + path: 'travel/asia', + mobileOverride: 'tag-list', + }, + { + title: 'UK', + path: 'travel/uk', + }, + { + title: 'Europe', + path: 'travel/europe', + }, + { + title: 'US', + path: 'travel/usa', + }, + { + title: 'Skiing', + path: 'travel/skiing', + }, + ], + }, + { + title: 'Media', + path: 'au/media', + sections: [], + }, + { + title: 'Environment', + path: 'au/environment', + sections: [ + { + title: 'Climate crisis', + path: 'environment/climate-crisis', + }, + { + title: 'Energy', + path: 'environment/energy', + }, + { + title: 'Wildlife', + path: 'environment/wildlife', + }, + { + title: 'Biodiversity', + path: 'environment/biodiversity', + }, + { + title: 'Oceans', + path: 'environment/oceans', + }, + { + title: 'Pollution', + path: 'environment/pollution', + }, + { + title: 'Great Barrier Reef', + path: 'environment/great-barrier-reef', + }, + ], + }, + { + title: 'Health', + path: 'australia-news/health', + sections: [], + }, + { + title: 'Science', + path: 'science', + sections: [], + }, + { + title: 'Video', + path: 'video', + sections: [], + }, + { + title: 'Podcasts', + path: 'podcasts', + sections: [ + { + title: 'Full Story', + path: 'australia-news/series/full-story', + sections: [], + }, + { + title: 'Politics', + path: 'australia-news/series/australian-politics-live', + sections: [], + }, + { + title: 'Politics Weekly UK', + path: 'politics/series/politicsweekly', + sections: [], + }, + { + title: 'Politics Weekly America', + path: 'politics/series/politics-weekly-america', + sections: [], + }, + { + title: 'Today in Focus', + path: 'news/series/todayinfocus', + sections: [], + }, + { + title: 'Weekend', + path: 'lifeandstyle/series/weekend', + sections: [], + }, + { + title: 'Football', + path: 'football/series/footballweekly', + sections: [], + }, + { + title: 'Audio Long Reads', + path: 'news/series/the-audio-long-read', + sections: [], + }, + { + title: 'Science', + path: 'science/series/science', + sections: [], + }, + { + title: 'Books', + path: 'books/series/books', + sections: [], + }, + { + title: 'Tech', + path: 'technology/series/chips-with-everything', + sections: [], + }, + { + title: 'The Story', + path: 'news/series/the-story', + sections: [], + }, + ], + }, + { + title: 'Obituaries', + path: 'tone/obituaries', + sections: [], + }, + { + title: 'Support us', + path: 'insidetheguardian', + sections: [], + }, + { + title: 'Corrections', + path: 'theguardian/series/corrections-and-clarifications', + sections: [], + }, + ], +}; diff --git a/dotcom-rendering/fixtures/manual/appsNav/uk.ts b/dotcom-rendering/fixtures/manual/appsNav/uk.ts new file mode 100644 index 00000000000..c57b2f27a31 --- /dev/null +++ b/dotcom-rendering/fixtures/manual/appsNav/uk.ts @@ -0,0 +1,452 @@ +export const ukNav = { + pillars: [ + { + title: 'UK', + path: 'uk-news', + sections: [ + { + title: 'Politics', + path: 'politics', + }, + { + title: 'Education', + path: 'education', + }, + { + title: 'Media', + path: 'uk/media', + }, + { + title: 'Society', + path: 'society', + }, + { + title: 'Law', + path: 'law', + mobileOverride: 'section-list', + }, + { + title: 'Scotland', + path: 'uk/scotland', + }, + { + title: 'Wales', + path: 'uk/wales', + }, + { + title: 'Northern Ireland', + path: 'uk/northernireland', + }, + ], + }, + { + title: 'US politics', + path: 'us-news/us-politics', + sections: [], + }, + { + title: 'Politics', + path: 'politics', + sections: [], + }, + { + title: 'World', + path: 'world', + sections: [ + { + title: 'Europe', + path: 'world/europe-news', + }, + { + title: 'US', + path: 'us-news', + }, + { + title: 'Americas', + path: 'world/americas', + }, + { + title: 'Asia', + path: 'world/asia', + }, + { + title: 'Australia', + path: 'australia-news', + }, + { + title: 'Africa', + path: 'world/africa', + }, + { + title: 'Middle East', + path: 'world/middleeast', + }, + { + title: 'Ukraine', + path: 'world/ukraine', + }, + { + title: 'Development', + path: 'global-development', + }, + ], + }, + { + title: 'Sport', + path: 'uk/sport', + sections: [ + { + title: 'Football', + path: 'football', + }, + { + title: 'Cricket', + path: 'sport/cricket', + }, + { + title: 'Rugby union', + path: 'sport/rugby-union', + }, + { + title: 'F1', + path: 'sport/formulaone', + }, + { + title: 'Tennis', + path: 'sport/tennis', + }, + { + title: 'Golf', + path: 'sport/golf', + }, + { + title: 'Cycling', + path: 'sport/cycling', + }, + { + title: 'Boxing', + path: 'sport/boxing', + }, + { + title: 'Racing', + path: 'sport/horse-racing', + }, + { + title: 'Rugby league', + path: 'sport/rugbyleague', + }, + { + title: 'US sports', + path: 'sport/us-sport', + }, + ], + }, + { + title: 'Football', + path: 'football', + sections: [], + }, + { + title: 'Opinion', + path: 'uk/commentisfree', + sections: [], + }, + { + title: 'Culture', + path: 'uk/culture', + sections: [ + { + title: 'Film', + path: 'uk/film', + }, + { + title: 'TV & radio', + path: 'tv-and-radio', + }, + { + title: 'Music', + path: 'music', + }, + { + title: 'Games', + path: 'games', + }, + { + title: 'Books', + path: 'books', + }, + { + title: 'Art & design', + path: 'artanddesign', + }, + { + title: 'Stage', + path: 'stage', + }, + { + title: 'Classical', + path: 'music/classical-music-and-opera', + }, + ], + }, + { + title: 'Environment', + path: 'uk/environment', + sections: [ + { + title: 'Climate crisis', + path: 'environment/climate-crisis', + }, + { + title: 'Wildlife', + path: 'environment/wildlife', + }, + { + title: 'Energy', + path: 'environment/energy', + }, + { + title: 'Pollution', + path: 'environment/pollution', + }, + ], + }, + { + title: 'Climate crisis', + path: 'environment/climate-crisis', + sections: [], + }, + { + title: 'Business', + path: 'uk/business', + sections: [ + { + title: 'Economics', + path: 'business/economics', + }, + { + title: 'Banking', + path: 'business/banking', + }, + { + title: 'Retail', + path: 'business/retail', + }, + { + title: 'Markets', + path: 'business/stock-markets', + }, + { + title: 'Eurozone', + path: 'business/eurozone', + }, + { + title: 'B2B', + path: 'business-to-business', + }, + ], + }, + { + title: 'Lifestyle', + path: 'lifeandstyle', + sections: [ + { + title: 'Food', + path: 'food', + }, + { + title: 'The Filter', + path: 'uk/thefilter', + }, + { + title: 'Health & fitness', + path: 'lifeandstyle/health-and-wellbeing', + }, + { + title: 'Love & sex', + path: 'lifeandstyle/love-and-sex', + }, + { + title: 'Beauty', + path: 'fashion/beauty', + }, + { + title: 'Cars', + path: 'technology/motoring', + }, + { + title: 'Women', + path: 'lifeandstyle/women', + }, + { + title: 'Home & garden', + path: 'lifeandstyle/home-and-garden', + }, + ], + }, + { + title: 'Fashion', + path: 'fashion', + sections: [], + }, + { + title: 'Tech', + path: 'uk/technology', + sections: [], + }, + { + title: 'Travel', + path: 'travel', + sections: [ + { + title: 'UK', + path: 'travel/uk', + }, + { + title: 'Europe', + path: 'travel/europe', + }, + { + title: 'US', + path: 'travel/usa', + }, + { + title: 'Skiing', + path: 'travel/skiing', + }, + ], + }, + { + title: 'Money', + path: 'uk/money', + sections: [ + { + title: 'Property', + path: 'money/property', + }, + { + title: 'Savings', + path: 'money/savings', + }, + { + title: 'Pensions', + path: 'money/pensions', + }, + { + title: 'Borrowing', + path: 'money/debt', + }, + { + title: 'Careers', + path: 'money/work-and-careers', + }, + ], + }, + { + title: 'Science', + path: 'science', + sections: [], + }, + { + title: 'Education', + path: 'education', + sections: [ + { + title: 'Students', + path: 'education/students', + }, + ], + }, + { + title: 'Society', + path: 'society', + sections: [], + }, + { + title: 'Media', + path: 'uk/media', + sections: [], + }, + { + title: 'Obituaries', + path: 'tone/obituaries', + sections: [], + }, + { + title: 'Video', + path: 'video', + sections: [], + }, + { + title: 'Podcasts', + path: 'podcasts', + sections: [ + { + title: 'Today in Focus', + path: 'news/series/todayinfocus', + sections: [], + }, + { + title: 'Football Weekly', + path: 'football/series/footballweekly', + sections: [], + }, + { + title: 'Comfort Eating', + path: 'lifeandstyle/series/comforteatingwithgracedent', + sections: [], + }, + { + title: 'Audio Long Reads', + path: 'news/series/the-audio-long-read', + sections: [], + }, + { + title: 'Politics Weekly UK', + path: 'politics/series/politicsweekly', + sections: [], + }, + { + title: 'Politics Weekly America', + path: 'politics/series/politics-weekly-america', + sections: [], + }, + { + title: 'Pop Culture', + path: 'society/series/pop-culture-with-chante-joseph', + sections: [], + }, + { + title: 'Weekend', + path: 'lifeandstyle/series/weekend', + sections: [], + }, + { + title: 'Guardian Books', + path: 'books/series/books', + sections: [], + }, + { + title: 'Australian Politics', + path: 'australia-news/series/australian-politics-live', + sections: [], + }, + ], + }, + { + title: 'Live events', + path: 'guardian-live-events', + sections: [], + }, + { + title: 'Support us', + path: 'insidetheguardian', + sections: [], + }, + { + title: 'Corrections', + path: 'theguardian/series/corrections-and-clarifications', + sections: [], + }, + ], +}; diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index cf57a5826a1..6b1c2032a6d 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -162,7 +162,7 @@ "typescript": "5.5.3", "typescript-json-schema": "0.64.0", "unified": "11.0.5", - "valibot": "0.28.1", + "valibot": "1.1.0", "web-vitals": "4.2.3", "webpack": "5.102.1", "webpack-assets-manifest": "6.3.0", diff --git a/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.stories.tsx b/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.stories.tsx new file mode 100644 index 00000000000..1c489837552 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.stories.tsx @@ -0,0 +1,169 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { fn, within } from 'storybook/test'; +import { parse } from 'valibot'; +import { auNav } from '../../../fixtures/manual/appsNav/au'; +import { ukNav } from '../../../fixtures/manual/appsNav/uk'; +import { error, ok } from '../../lib/result'; +import { AppsNavSchema } from './appsNav'; +import type { PublishResult } from './AppsNavTool'; +import { AppsNavTool } from './AppsNavTool'; + +const meta = { + title: 'Admin/Apps Nav Tool', + component: AppsNavTool, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const UKNav = { + args: { + nav: parse(AppsNavSchema, ukNav), + editionId: 'UK', + guardianBaseUrl: 'https://www.theguardian.com', + publish: fn(() => Promise.resolve(ok(true))), + }, +} satisfies Story; + +export const AUNav = { + args: { + ...UKNav.args, + nav: parse(AppsNavSchema, auNav), + editionId: 'AU', + }, +}; + +export const AddSectionForm = { + ...UKNav, + play: async ({ canvas, userEvent }) => { + const addSection = canvas.getByRole('button', { name: 'Add Section' }); + + await userEvent.click(addSection); + }, +} satisfies Story; + +export const EditSectionForm = { + ...UKNav, + play: async ({ canvas, userEvent }) => { + // ! is used here to make sure we get an error in storybook if this + // can't be found. + const addSection = canvas.getAllByRole('button', { name: 'Edit' })[0]!; + + await userEvent.click(addSection); + }, +} satisfies Story; + +export const PublishConfirm = { + ...UKNav, + play: async ({ canvas, userEvent, step }) => { + await step('Delete the first section', async () => { + // ! is used here to make sure we get an error in storybook if this + // can't be found. + const deleteSection = canvas.getAllByRole('button', { + name: 'Delete', + })[0]!; + await userEvent.click(deleteSection); + }); + + await step('Move the sixth section up', async () => { + const moveSectionUp = canvas.getAllByRole('button', { + name: 'Move Up', + })[5]!; + await userEvent.click(moveSectionUp); + }); + + await step('Move the first section down', async () => { + const moveSectionDown = canvas.getAllByRole('button', { + name: 'Move Down', + })[0]!; + await userEvent.click(moveSectionDown); + }); + + await step('Edit the fourth section', async () => { + const editSection = canvas.getAllByRole('button', { + name: 'Edit', + })[3]!; + await userEvent.click(editSection); + + const editDialog = canvas.getAllByRole('dialog', { + hidden: true, + })[1]!; + + const titleInput = within(editDialog).getByLabelText('Title', { + selector: 'input', + }); + await userEvent.type(titleInput, 'US Money'); + + const urlInput = within(editDialog).getByLabelText('Dotcom Link', { + selector: 'input', + }); + await userEvent.type(urlInput, 'us/money'); + + const submit = within(editDialog).getByRole('button', { + name: 'Submit', + }); + await userEvent.click(submit); + }); + + await step('Add a new section', async () => { + const addSection = canvas.getByRole('button', { + name: 'Add Section', + }); + await userEvent.click(addSection); + + const insertDialog = canvas.getAllByRole('dialog', { + hidden: true, + })[0]!; + + const titleInput = within(insertDialog).getByLabelText('Title', { + selector: 'input', + }); + await userEvent.type(titleInput, 'Classical'); + + const urlInput = within(insertDialog).getByLabelText( + 'Dotcom Link', + { + selector: 'input', + }, + ); + await userEvent.type( + urlInput, + 'https://www.theguardian.com/music/classical-music-and-opera', + ); + + const submit = within(insertDialog).getByRole('button', { + name: 'Submit', + }); + await userEvent.click(submit); + }); + + const publish = canvas.getByRole('button', { name: 'Publish' }); + await userEvent.click(publish); + }, +} satisfies Story; + +export const PublishError = { + args: { + ...UKNav.args, + publish: fn(() => + Promise.resolve(error('NetworkError')), + ), + }, + play: async ({ canvas, userEvent }) => { + // ! is used here to make sure we get an error in storybook if this + // can't be found. + const addSection = canvas.getAllByRole('button', { + name: 'Move Down', + })[0]!; + await userEvent.click(addSection); + + const publish = canvas.getByRole('button', { name: 'Publish' }); + await userEvent.click(publish); + + const confirmPublish = canvas.getAllByRole('button', { + name: 'Publish', + })[1]!; + await userEvent.click(confirmPublish); + }, +} satisfies Story; diff --git a/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.tsx b/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.tsx new file mode 100644 index 00000000000..0afb83e6ab6 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/AppsNavTool.tsx @@ -0,0 +1,81 @@ +import { headlineMedium34Object, space } from '@guardian/source/foundations'; +import { useCallback, useReducer } from 'react'; +import { type EditionId, getEditionFromId } from '../../lib/edition'; +import type { Result } from '../../lib/result'; +import { type AppsNav } from './appsNav'; +import { EditDialog } from './EditDialog'; +import type { PublishError } from './error'; +import { InsertDialog } from './InsertDialog'; +import { MenuButtons } from './MenuButtons'; +import { PublishDialog } from './PublishDialog'; +import { Sections } from './Sections'; +import { DispatchContext, reducer } from './state'; +import { StatusMessage } from './StatusMessage'; + +type Props = { + nav: AppsNav; + editionId: EditionId; + guardianBaseUrl: string; + publish: (data: AppsNav) => Promise; +}; + +export type PublishResult = Result; + +export const AppsNavTool = (props: Props) => { + const [state, dispatch] = useReducer(reducer, { + sections: props.nav.pillars, + history: [], + prepublish: false, + }); + + const publish = async () => { + const result = await props.publish({ pillars: state.sections }); + + if (result.kind === 'ok') { + dispatch({ kind: 'publishSuccess' }); + } else { + dispatch({ kind: 'publishError', error: result.error }); + } + }; + + const prePublish = useCallback(() => { + dispatch({ kind: 'prePublish' }); + }, [dispatch]); + + return ( + + + + + + + + + + ); +}; + +const Heading = (props: { editionId: EditionId }) => ( +

+ {`${getEditionFromId(props.editionId).title} apps nav`} +

+); diff --git a/dotcom-rendering/src/admin/appsNavTool/Dialog.tsx b/dotcom-rendering/src/admin/appsNavTool/Dialog.tsx new file mode 100644 index 00000000000..95b6f4af3b6 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/Dialog.tsx @@ -0,0 +1,53 @@ +import { + headlineBold24Object, + palette, + space, +} from '@guardian/source/foundations'; +import { type ReactNode, useEffect, useRef } from 'react'; + +type Props = { + children: ReactNode; + open: boolean; + heading: string; +}; + +export const Dialog = (props: Props) => { + const dialogRef = useRef(null); + + // Required for the `::backdrop` pseudo-element, otherwise we could use the + // `open` attribute. + useEffect(() => { + if (props.open) { + dialogRef.current?.showModal(); + } else { + dialogRef.current?.close(); + } + }, [props.open]); + + return ( + + {props.heading} + {props.children} + + ); +}; + +const Heading = (props: { children: ReactNode }) => ( +

+ {props.children} +

+); diff --git a/dotcom-rendering/src/admin/appsNavTool/EditDialog.tsx b/dotcom-rendering/src/admin/appsNavTool/EditDialog.tsx new file mode 100644 index 00000000000..3b70f9a8a7f --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/EditDialog.tsx @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { SectionForm } from './SectionForm'; +import { type State, useDispatch } from './state'; + +type Props = { + editing: State['editing'] | undefined; +}; + +export const EditDialog = (props: Props) => { + const dispatch = useDispatch(); + + const submit = (title: string, url: URL) => { + if (props.editing !== undefined) { + dispatch({ + kind: 'update', + title, + path: url.pathname.slice(1), + location: props.editing.location, + }); + } + }; + + const cancel = useCallback(() => { + dispatch({ kind: 'cancelEdit' }); + }, [dispatch]); + + if (props.editing === undefined) { + return null; + } + + return ( + + ); +}; diff --git a/dotcom-rendering/src/admin/appsNavTool/InsertDialog.tsx b/dotcom-rendering/src/admin/appsNavTool/InsertDialog.tsx new file mode 100644 index 00000000000..17167524fb2 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/InsertDialog.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { SectionForm } from './SectionForm'; +import { type State, useDispatch } from './state'; + +type Props = { + insertingAt: State['insertingAt'] | undefined; +}; + +export const InsertDialog = (props: Props) => { + const dispatch = useDispatch(); + + const submit = (title: string, url: URL) => { + if (props.insertingAt !== undefined) { + dispatch({ + kind: 'insert', + section: { + title, + path: url.pathname.slice(1), + }, + location: props.insertingAt, + }); + } + }; + + const cancel = useCallback(() => { + dispatch({ kind: 'cancelInsert' }); + }, [dispatch]); + + if (props.insertingAt === undefined) { + return null; + } + + return ( + + ); +}; diff --git a/dotcom-rendering/src/admin/appsNavTool/MenuButtons.tsx b/dotcom-rendering/src/admin/appsNavTool/MenuButtons.tsx new file mode 100644 index 00000000000..8693ce0e104 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/MenuButtons.tsx @@ -0,0 +1,126 @@ +import { space } from '@guardian/source/foundations'; +import { + Button, + SvgArrowOutdent, + SvgPlus, + SvgReload, + SvgUpload, +} from '@guardian/source/react-components'; +import type { ReactNode } from 'react'; +import type { Section } from './appsNav'; +import { type HistoryEvent, useDispatch } from './state'; + +type Props = { + initialSections: Section[]; + history: HistoryEvent[]; + publish: () => void; +}; + +export const MenuButtons = (props: Props) => ( + + + + + + +); + +const Menu = (props: { children: ReactNode }) => ( + + {props.children} + +); + +const Undo = (props: { history: Props['history'] }) => { + const dispatch = useDispatch(); + + return ( +
  • + +
  • + ); +}; + +const Reset = (props: { + history: Props['history']; + initialSections: Props['initialSections']; +}) => { + const dispatch = useDispatch(); + + return ( +
  • + +
  • + ); +}; + +const AddSection = () => { + const dispatch = useDispatch(); + + return ( +
  • + +
  • + ); +}; + +const Publish = (props: { + publish: Props['publish']; + history: HistoryEvent[]; +}) => ( +
  • + +
  • +); diff --git a/dotcom-rendering/src/admin/appsNavTool/PublishDialog.tsx b/dotcom-rendering/src/admin/appsNavTool/PublishDialog.tsx new file mode 100644 index 00000000000..4efd376423c --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/PublishDialog.tsx @@ -0,0 +1,175 @@ +import { css } from '@emotion/react'; +import { + headlineBold17Object, + palette, + space, + textEgyptian17Object, +} from '@guardian/source/foundations'; +import { + Button, + SvgArrowDownStraight, + SvgArrowUpStraight, + SvgBin, + SvgEdit, + SvgPlus, + SvgUpload, +} from '@guardian/source/react-components'; +import { type FormEventHandler, type ReactNode, useCallback } from 'react'; +import type { Section } from './appsNav'; +import { Dialog } from './Dialog'; +import { type HistoryEvent } from './state'; +import { useDispatch } from './state'; + +type Props = { + history: HistoryEvent[]; + open: boolean; + publish: () => void; +}; + +export const PublishDialog = (props: Props) => { + const { publish } = props; + + const submit: FormEventHandler = useCallback( + (e) => { + e.preventDefault(); + props.publish(); + }, + [publish], + ); + + return ( + +
    +

    + Here is a summary of your changes: +

    + + + +
    + ); +}; + +const Summary = (props: { history: HistoryEvent[] }) => ( +
      + {props.history.toReversed().map((evt) => ( + + ))} +
    +); + +const EventSummary = (props: { event: HistoryEvent }) => { + switch (props.event.kind) { + case 'delete': + return ; + case 'insert': + return ; + case 'move': + return ( + + ); + case 'update': + return ( + + ); + } +}; + +const DeleteSummary = (props: { section: Section }) => ( + } iconColour={palette.error[400]}> + Deleted the {props.section.title} section + +); + +const InsertSummary = (props: { section: Section }) => ( + } iconColour={palette.success[400]}> + Inserted the {props.section.title} section + +); + +const MoveSummary = (props: { distance: number; section: Section }) => { + const movedUp = props.distance < 0; + + return ( + : } + iconColour={palette.brand[500]} + > + Moved the {props.section.title} section{' '} + {movedUp ? ' up ' : ' down '} {Math.abs(props.distance)} + + ); +}; + +const UpdateSummary = (props: { from: Section; to: Section }) => ( + } iconColour={palette.opinion[400]}> + Edited the {props.from.title} ({props.from.path}) + section to be {props.to.title} ({props.to.path}) + +); + +const SummaryItem = (props: { + icon: ReactNode; + iconColour: `#${string}`; + children: ReactNode; +}) => ( +
  • + {props.icon} + {props.children} +
  • +); + +const Icon = (props: { children: ReactNode; colour: `#${string}` }) => ( + + {props.children} + +); + +const Buttons = () => { + const dispatch = useDispatch(); + + const cancel = useCallback(() => { + dispatch({ kind: 'cancelPrePublish' }); + }, [dispatch]); + + return ( + <> + + + + ); +}; diff --git a/dotcom-rendering/src/admin/appsNavTool/SectionButtons.tsx b/dotcom-rendering/src/admin/appsNavTool/SectionButtons.tsx new file mode 100644 index 00000000000..83016025129 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/SectionButtons.tsx @@ -0,0 +1,160 @@ +import { css } from '@emotion/react'; +import { space } from '@guardian/source/foundations'; +import { + Button, + SvgArrowDownStraight, + SvgArrowUpStraight, + SvgBin, + SvgEdit, + SvgPlus, +} from '@guardian/source/react-components'; +import type { MobileOverride } from './appsNav'; +import { useDispatch } from './state'; + +type Props = { + location: number[]; + title: string; + path: string; + mobileOverride: MobileOverride | undefined; + index: number; + numberOfSections: number; +}; + +export const SectionButtons = (props: Props) => ( + <> + + + + + + +); + +const Edit = (props: { + location: Props['location']; + title: Props['title']; + path: Props['path']; + mobileOverride: Props['mobileOverride']; +}) => { + const dispatch = useDispatch(); + + return ( + + ); +}; + +const Delete = (props: { location: Props['location'] }) => { + const dispatch = useDispatch(); + + return ( + + ); +}; + +const MoveUp = (props: { location: Props['location']; index: number }) => { + const dispatch = useDispatch(); + + return ( + + ); +}; + +const MoveDown = (props: { + location: Props['location']; + index: number; + numberOfSections: number; +}) => { + const dispatch = useDispatch(); + + return ( + + ); +}; + +const AddSubsection = (props: { location: Props['location'] }) => { + const dispatch = useDispatch(); + + return ( + + ); +}; diff --git a/dotcom-rendering/src/admin/appsNavTool/SectionForm.tsx b/dotcom-rendering/src/admin/appsNavTool/SectionForm.tsx new file mode 100644 index 00000000000..e085d4b45b0 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/SectionForm.tsx @@ -0,0 +1,148 @@ +import { css } from '@emotion/react'; +import { space } from '@guardian/source/foundations'; +import { Button, Select, TextInput } from '@guardian/source/react-components'; +import { + type ChangeEventHandler, + type FormEventHandler, + useCallback, + useState, +} from 'react'; +import { type MobileOverride, mobileOverrideOptions } from './appsNav'; +import { Dialog } from './Dialog'; + +type Props = { + heading: string; + open: boolean; + initialTitle: string; + initialPath: string; + initialMobileOverride: MobileOverride | undefined; + submit: ( + title: string, + url: URL, + mobileOverride: MobileOverride | undefined, + ) => void; + cancel: () => void; +}; + +export const SectionForm = (props: Props) => { + const [title, setTitle] = useState(props.initialTitle); + const [url, setUrl] = useState(props.initialPath); + const [mobileOverride, setMobileOverride] = useState< + MobileOverride | undefined + >(props.initialMobileOverride); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + props.submit(title, new URL(url), mobileOverride); + }; + + return ( + +
    + + + + + +
    + ); +}; + +const TitleInput = (props: { + title: string; + setTitle: (a: string) => void; +}) => ( + props.setTitle(e.target.value)} + cssOverrides={css({ + marginBottom: space[5], + })} + /> +); + +const UrlInput = (props: { url: string; setUrl: (a: string) => void }) => ( + props.setUrl(e.target.value)} + cssOverrides={css({ + marginBottom: space[5], + })} + /> +); + +const MobileOverrideSelect = (props: { + mobileOverride: MobileOverride | undefined; + setMobileOverride: (a: MobileOverride | undefined) => void; +}) => { + const { setMobileOverride } = props; + + const onChange = useCallback>( + (e) => { + if (isMobileOverride(e.target.value)) { + props.setMobileOverride(e.target.value); + } else { + props.setMobileOverride(undefined); + } + }, + [setMobileOverride], + ); + + return ( + + ); +}; + +/** + * `Array.includes` requires the value passed to it to be the same type as the + * elements in the array. However, we have the following: + * + * - Elements in the array are a union of string literals, i.e. a fixed list of + * specific strings. + * - The value we need to pass to `includes` (`event.target.value`) is of type + * `string`. + * + * `string` is a wider type than our union of string literals, and you can't + * pass a wider type to a narrower one. Therefore, we have to write this type + * guard with some type casting if we want to use `includes`. The reason we want + * to use `includes` is because it will automatically include any changes to the + * `mobileOverrideOptions` array. Otherwise we could use `switch` or `if` to + * narrow a predefined list of values. + */ +const isMobileOverride = (s: string): s is MobileOverride => + (mobileOverrideOptions as unknown as string[]).includes(s); + +const Buttons = (props: { cancel: Props['cancel'] }) => ( + <> + + + +); diff --git a/dotcom-rendering/src/admin/appsNavTool/Sections.tsx b/dotcom-rendering/src/admin/appsNavTool/Sections.tsx new file mode 100644 index 00000000000..5c9ab6537de --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/Sections.tsx @@ -0,0 +1,158 @@ +import { + headlineBold17Object, + palette, + space, +} from '@guardian/source/foundations'; +import { SvgChevronDownSingle } from '@guardian/source/react-components'; +import type { ReactNode } from 'react'; +import { type Section as SectionModel } from './appsNav'; +import { SectionButtons } from './SectionButtons'; + +type Props = { + sections: SectionModel[]; + guardianBaseUrl: string; + location: number[]; +}; + +export const Sections = (props: Props) => ( +
      + {props.sections.map((section, index, sections) => ( +
      + ))} +
    +); + +const Section = (props: { + section: SectionModel; + guardianBaseUrl: string; + location: number[]; + index: number; + numberOfSections: number; +}) => ( +
  • + + + + {props.section.title}{' '} + + +
  • +); + +const Li = (props: { children: ReactNode }) => ( +
  • + {props.children} +
  • +); + +const WithSubSections = (props: { + subSections: SectionModel[] | undefined; + guardianBaseUrl: Props['guardianBaseUrl']; + location: Props['location']; + children: ReactNode; +}) => { + if (props.subSections === undefined || props.subSections.length === 0) { + return <>{props.children}; + } + + return ( +
    + {props.children} + +
    + ); +}; + +const Summary = (props: { children: ReactNode }) => ( + + {props.children} + +); + +const Chevron = (props: { subSections: SectionModel[] | undefined }) => { + const hasSubSections = + props.subSections !== undefined && props.subSections.length > 0; + + return ( + + {hasSubSections ? : null} + + ); +}; + +const Title = (props: { children: ReactNode }) => ( + + {props.children} + +); + +const Path = (props: { path: string; guardianBaseUrl: string }) => ( + + + {props.path} + + +); diff --git a/dotcom-rendering/src/admin/appsNavTool/StatusMessage.stories.tsx b/dotcom-rendering/src/admin/appsNavTool/StatusMessage.stories.tsx new file mode 100644 index 00000000000..ddc2694c532 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/StatusMessage.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { error, ok } from '../../lib/result'; +import { StatusMessage } from './StatusMessage'; + +const meta = { + title: 'Admin/Apps Nav Tool/Status Message', + component: StatusMessage, + parameters: { + chromatic: { disable: true }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const PublicationSuccess = { + args: { + message: ok('Publication successful.'), + }, +} satisfies Story; + +export const PublishErrorNetwork = { + args: { + message: error({ + kind: 'PublishError', + details: 'NetworkError', + }), + }, +} satisfies Story; + +export const PublishErrorVersionMismatch = { + args: { + message: error({ + kind: 'PublishError', + details: 'VersionMismatch', + }), + }, +} satisfies Story; + +export const PublishErrorServer = { + args: { + message: error({ + kind: 'PublishError', + details: 'ServerError', + }), + }, +} satisfies Story; + +export const InsertError = { + args: { + message: error({ + kind: 'InsertError', + details: 'NoIndex', + location: [2, 1], + }), + }, +} satisfies Story; + +export const DeleteError = { + args: { + message: error({ + kind: 'DeleteError', + details: 'NoSectionAtLocation', + location: [0, 3], + }), + }, +} satisfies Story; + +export const MoveError = { + args: { + message: error({ + kind: 'MoveError', + details: 'OutOfRange', + location: [0, 3], + distance: -1, + }), + }, +} satisfies Story; + +export const UpdateError = { + args: { + message: error({ + kind: 'UpdateError', + details: 'NoSectionAtLocation', + location: [5], + }), + }, +} satisfies Story; + +export const NoHistoryError = { + args: { + message: error({ kind: 'NoHistoryError' }), + }, +} satisfies Story; diff --git a/dotcom-rendering/src/admin/appsNavTool/StatusMessage.tsx b/dotcom-rendering/src/admin/appsNavTool/StatusMessage.tsx new file mode 100644 index 00000000000..c1722f50e06 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/StatusMessage.tsx @@ -0,0 +1,166 @@ +import { css } from '@emotion/react'; +import { + palette, + space, + textEgyptian17Object, +} from '@guardian/source/foundations'; +import { InlineError, InlineSuccess } from '@guardian/source/react-components'; +import type { ReactNode } from 'react'; +import type { AppsNavError } from './error'; +import type { State } from './state'; + +type Props = { + message: State['statusMessage']; +}; + +export const StatusMessage = (props: Props) => { + if (props.message === undefined) { + return null; + } + + return ( + + + + ); +}; + +type Message = Exclude; + +const Output = (props: { + messageKind: Message['kind']; + children: ReactNode; +}) => ( + + {props.children} + +); + +const MessageText = (props: { message: Message }) => { + switch (props.message.kind) { + case 'ok': + return {props.message.value}; + case 'error': + return ; + } +}; + +const ErrorMessage = (props: { error: AppsNavError }) => { + switch (props.error.kind) { + case 'PublishError': + switch (props.error.details) { + case 'NetworkError': + return ( + <> + + Publication issue due to a  + network error. + + Publication may or may not have succeeded, so you + may want to check in the apps. If you retry and get + a version error then it's possible + that publication succeeded. + + ); + case 'VersionMismatch': + return ( + <> + + Publication issue due to a  + version error. + + It appears the previous version of the nav has + changed since you started editing it, which means + someone else may be editing it at the same time. You + will likely have to refresh and start again. + + ); + case 'ServerError': + return ( + <> + + Publication issue due to a  + server error. + + You could try again, but if the problem persists you + may want to contact the team who maintains this + tool. + + ); + } + case 'InsertError': + return ( + + Could not insert the section. ({props.error.details} error + occured for location [{props.error.location.join(', ')}]) + + ); + case 'DeleteError': + return ( + + Could not delete the section. ({props.error.details} error + occured for location [{props.error.location.join(', ')}]) + + ); + case 'MoveError': + return ( + + Could not move the section. ({props.error.details} error + occured for location [{props.error.location.join(', ')}], + when attempting to move + {props.error.distance < 0 ? ' up ' : ' down '} + {Math.abs(props.error.distance)}) + + ); + case 'UpdateError': + return ( + + Could not update the section. ({props.error.details} error + occured for location [{props.error.location.join(', ')}]) + + ); + case 'NoHistoryError': + return ( + + Could not undo anything, there is no history. + + ); + } +}; + +const Strong = (props: { children: ReactNode }) => ( + + {props.children} + +); + +const UnexpectedError = (props: { children: ReactNode }) => ( + <> + {props.children} + This problem should not occur, so you may want to contact the team who + maintains this tool, with steps to reproduce the problem if possible. + +); diff --git a/dotcom-rendering/src/admin/appsNavTool/appsNav.test.ts b/dotcom-rendering/src/admin/appsNavTool/appsNav.test.ts new file mode 100644 index 00000000000..ec866f8c9e2 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/appsNav.test.ts @@ -0,0 +1,93 @@ +import { parse } from 'valibot'; +import { ukNav } from '../../../fixtures/manual/appsNav/uk'; +import { okOrThrow } from '../../lib/result'; +import { + AppsNavSchema, + deleteSection, + insertSection, + moveSection, + updateSection, +} from './appsNav'; + +describe('appsNav', () => { + const sections = parse(AppsNavSchema, ukNav).pillars; + + it('deletes a top-level section', () => { + const result = okOrThrow( + deleteSection(sections, [0]), + 'Expected section deletion to be successful', + ); + + expect(result.deleted?.title).toBe('UK'); + expect(result.sections[0]?.title).toEqual('US politics'); + }); + + it('deletes a second-level section', () => { + const result = okOrThrow( + deleteSection(sections, [0, 2]), + 'Expected section deletion to be successful', + ); + + expect(result.deleted?.title).toBe('Media'); + expect(result.sections?.[0]?.sections?.[2]?.title).toEqual('Society'); + }); + + it('inserts sections', () => { + const section = { + title: 'Mock Section', + path: 'mock-section', + }; + + const result = okOrThrow( + insertSection(sections, [0], section), + 'Expected section insertion to be successful', + ); + + expect(result[0]?.title).toEqual('Mock Section'); + expect(result[1]?.title).toEqual('UK'); + + const result2 = okOrThrow( + insertSection(result, [1, 2], section), + 'Expected section insertion to be successful', + ); + + expect(result2?.[1]?.sections?.[2]?.title).toEqual('Mock Section'); + expect(result2[1]?.title).toEqual('UK'); + }); + + it('moves sections', () => { + const result = okOrThrow( + moveSection(sections, [0, 2], 3), + 'Expected section move to be successful', + ); + + expect(result.sections[0]?.sections?.length).toBe(8); + expect(result.sections?.[0]?.sections?.[2]?.title).toEqual('Society'); + expect(result.sections?.[0]?.sections?.[5]?.title).toEqual('Media'); + + const result2 = okOrThrow( + moveSection(sections, [0, 2], -2), + 'Expected section move to be successful', + ); + + expect(result2.sections[0]?.sections?.length).toBe(8); + expect(result2.sections?.[0]?.sections?.[0]?.title).toEqual('Media'); + expect(result2.sections?.[0]?.sections?.[2]?.title).toEqual( + 'Education', + ); + }); + + it('updates sections', () => { + const title = 'Mock Section'; + const path = 'mock-section'; + + const result = okOrThrow( + updateSection(sections, [0], title, path), + 'Expected section update to be successful', + ); + + expect(result.from.title).toEqual('UK'); + expect(result.sections[0]?.title).toEqual('Mock Section'); + expect(result.sections[0]?.path).toEqual('mock-section'); + }); +}); diff --git a/dotcom-rendering/src/admin/appsNavTool/appsNav.ts b/dotcom-rendering/src/admin/appsNavTool/appsNav.ts new file mode 100644 index 00000000000..df283f6f912 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/appsNav.ts @@ -0,0 +1,252 @@ +import { + array, + type GenericSchema, + type InferOutput, + lazy, + literal, + object, + optional, + string, + union, +} from 'valibot'; +import { error, ok, type Result } from '../../lib/result'; + +// We can't use `InferOutput` for this due to lazy. +// See https://valibot.dev/guides/other/ +export type Section = { + title: string; + path: string; + mobileOverride?: MobileOverride; + editionOverride?: EditionOverride; + sections?: Section[]; +}; + +export const mobileOverrideOptions = [ + 'section-front', + 'section-list', + 'tag-list', +] as const; + +const MobileOverrideSchema = union( + mobileOverrideOptions.map((v) => literal(v)), +); + +export type MobileOverride = InferOutput; + +const EditionOverrideSchema = union([ + literal('uk'), + literal('us'), + literal('au'), + literal('europe'), + literal('international'), +]); + +type EditionOverride = InferOutput; + +const SectionSchema: GenericSchema
    = object({ + title: string(), + path: string(), + mobileOverride: optional(MobileOverrideSchema), + sections: optional(array(lazy(() => SectionSchema))), + editionOverride: optional(EditionOverrideSchema), +}); + +export const AppsNavSchema = object({ + pillars: array(SectionSchema), +}); + +export type AppsNav = InferOutput; + +export type DeleteError = 'NoIndex' | 'NoSectionAtLocation'; + +type DeleteSuccess = { + deleted: Section; + sections: Section[]; +}; + +export const deleteSection = ( + sections: Section[], + location: number[], +): Result => { + const [index, ...rest] = location; + + if (index === undefined) { + return error('NoIndex'); + } + + const section = sections[index]; + + if (section === undefined) { + return error('NoSectionAtLocation'); + } + + if (rest.length === 0 || section.sections === undefined) { + return ok({ deleted: section, sections: sections.toSpliced(index, 1) }); + } + + const result = deleteSection(section.sections, rest); + + if (result.kind === 'error') { + return result; + } + + return ok({ + deleted: result.value.deleted, + sections: sections.toSpliced(index, 1, { + ...section, + sections: result.value.sections, + }), + }); +}; + +export type InsertError = + | 'NoIndex' + | 'NoSectionAtLocation' + | 'SectionExists' + | 'OutOfRange'; + +export function insertSection( + sections: Section[], + location: number[], + section: Section, +): Result { + const [index, ...rest] = location; + + if (index === undefined) { + return error('NoIndex'); + } + + if (rest.length === 0) { + if ( + sections.findIndex( + (value) => + value.title === section.title && + value.path === section.path, + ) !== -1 + ) { + return error('SectionExists'); + } + + if (index > sections.length) { + return error('OutOfRange'); + } + + return ok(sections.toSpliced(index, 0, section)); + } + + const currentSection = sections[index]; + + if (currentSection === undefined) { + return error('NoSectionAtLocation'); + } + + const result = insertSection(currentSection.sections ?? [], rest, section); + + if (result.kind === 'error') { + return result; + } + + return ok( + sections.toSpliced(index, 1, { + ...currentSection, + sections: result.value, + }), + ); +} + +export type MoveError = 'OutOfRange' | 'NoIndex'; + +type MoveSuccess = { + moved: Section; + sections: Section[]; +}; + +export const moveSection = ( + sections: Section[], + location: number[], + to: number, +): Result => { + if (location.length === 0) { + return error('NoIndex'); + } + + const deleteResult = deleteSection(sections, location); + + if (deleteResult.kind === 'error') { + return deleteResult; + } + + const { deleted, sections: remainingSections } = deleteResult.value; + const newLocation = (location.at(-1) ?? 0) + to; + + if (newLocation < 0) { + return error('OutOfRange'); + } + + const result = insertSection( + remainingSections, + location.toSpliced(-1, 1, newLocation), + deleted, + ); + + if (result.kind === 'error') { + return result; + } + + return ok({ + moved: deleted, + sections: result.value, + }); +}; + +export type UpdateError = 'NoIndex' | 'NoSectionAtLocation'; + +type UpdateSuccess = { + from: Section; + to: Section; + sections: Section[]; +}; + +export const updateSection = ( + sections: Section[], + location: number[], + title: string, + path: string, +): Result => { + const [index, ...rest] = location; + + if (index === undefined) { + return error('NoIndex'); + } + + const section = sections[index]; + + if (section === undefined) { + return error('NoSectionAtLocation'); + } + + if (rest.length === 0 || section.sections === undefined) { + const newSection = { ...section, title, path }; + + return ok({ + from: section, + sections: sections.toSpliced(index, 1, newSection), + to: newSection, + }); + } + + const result = updateSection(section.sections, rest, title, path); + + if (result.kind === 'error') { + return result; + } + + return ok({ + from: result.value.from, + to: result.value.to, + sections: sections.toSpliced(index, 1, { + ...section, + sections: result.value.sections, + }), + }); +}; diff --git a/dotcom-rendering/src/admin/appsNavTool/error.ts b/dotcom-rendering/src/admin/appsNavTool/error.ts new file mode 100644 index 00000000000..b547021cc33 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/error.ts @@ -0,0 +1,38 @@ +import type { + DeleteError, + InsertError, + MoveError, + UpdateError, +} from './appsNav'; + +export type AppsNavError = + | { + kind: 'PublishError'; + details: PublishError; + } + | { + kind: 'InsertError'; + details: InsertError; + location: number[]; + } + | { + kind: 'DeleteError'; + details: DeleteError; + location: number[]; + } + | { + kind: 'MoveError'; + details: MoveError | DeleteError | InsertError; + location: number[]; + distance: number; + } + | { + kind: 'UpdateError'; + details: UpdateError; + location: number[]; + } + | { + kind: 'NoHistoryError'; + }; + +export type PublishError = 'VersionMismatch' | 'NetworkError' | 'ServerError'; diff --git a/dotcom-rendering/src/admin/appsNavTool/state.ts b/dotcom-rendering/src/admin/appsNavTool/state.ts new file mode 100644 index 00000000000..b08548436b2 --- /dev/null +++ b/dotcom-rendering/src/admin/appsNavTool/state.ts @@ -0,0 +1,358 @@ +/** + * State management for the `AppsNavTool`. Relevant React docs: + * https://react.dev/learn/scaling-up-with-reducer-and-context + */ + +import { createContext, type Dispatch, useContext } from 'react'; +import { error, ok, type Result } from '../../lib/result'; +import { + deleteSection, + insertSection, + type MobileOverride, + moveSection, + type Section, + updateSection, +} from './appsNav'; +import type { AppsNavError, PublishError } from './error'; + +export type State = { + sections: Section[]; + history: HistoryEvent[]; + statusMessage?: Result; + insertingAt?: number[]; + prepublish: boolean; + editing?: { + title: string; + path: string; + mobileOverride: MobileOverride | undefined; + location: number[]; + }; +}; + +export type HistoryEvent = + | { + kind: 'delete'; + location: number[]; + section: Section; + } + | { + kind: 'insert'; + section: Section; + location: number[]; + } + | { + kind: 'move'; + section: Section; + distance: number; + from: number[]; + } + | { + kind: 'update'; + location: number[]; + from: Section; + to: Section; + }; + +type Action = + | { + kind: 'delete'; + location: number[]; + } + | { + kind: 'insert'; + location: number[]; + section: Section; + } + | { + kind: 'update'; + location: number[]; + title: string; + path: string; + } + | { + kind: 'undo'; + } + | { + kind: 'reset'; + initial: Section[]; + } + | { + kind: 'insertInto'; + location: number[]; + } + | { + kind: 'cancelInsert'; + } + | { + kind: 'edit'; + location: number[]; + title: string; + path: string; + mobileOverride: MobileOverride | undefined; + } + | { + kind: 'cancelEdit'; + } + | { + kind: 'moveDown'; + location: number[]; + } + | { + kind: 'moveUp'; + location: number[]; + } + | { + kind: 'prePublish'; + } + | { + kind: 'cancelPrePublish'; + } + | { + kind: 'publishSuccess'; + } + | { + kind: 'publishError'; + error: PublishError; + }; + +export const DispatchContext = createContext>(() => { + console.error('No dispatch function was provided to the DispatchContext'); +}); + +export const useDispatch = (): Dispatch => useContext(DispatchContext); + +const insertAction = ( + state: State, + location: number[], + section: Section, + history: HistoryEvent[] | undefined, +): State => { + const result = insertSection(state.sections, location, section); + + if (result.kind === 'error') { + return errorState( + { kind: 'InsertError', details: result.error, location }, + state, + ); + } + + return { + ...state, + sections: result.value, + insertingAt: undefined, + statusMessage: undefined, + history: history ?? [ + { kind: 'insert', location, section }, + ...state.history, + ], + }; +}; + +const deleteAction = ( + state: State, + location: number[], + history: HistoryEvent[] | undefined, +): State => { + const result = deleteSection(state.sections, location); + + if (result.kind === 'error') { + return errorState( + { kind: 'DeleteError', details: result.error, location }, + state, + ); + } + + return { + ...state, + sections: result.value.sections, + statusMessage: undefined, + history: history ?? [ + { + kind: 'delete', + location, + section: result.value.deleted, + }, + ...state.history, + ], + }; +}; + +const moveAction = ( + state: State, + location: number[], + distance: number, + history: HistoryEvent[] | undefined, +): State => { + const result = moveSection(state.sections, location, distance); + + if (result.kind === 'error') { + return errorState( + { kind: 'MoveError', details: result.error, location, distance }, + state, + ); + } + + return { + ...state, + sections: result.value.sections, + statusMessage: undefined, + history: history ?? [ + { + kind: 'move', + distance, + from: location, + section: result.value.moved, + }, + ...state.history, + ], + }; +}; + +const updateAction = ( + state: State, + location: number[], + title: string, + path: string, + history: HistoryEvent[] | undefined, +): State => { + const result = updateSection(state.sections, location, title, path); + + if (result.kind === 'error') { + return errorState( + { kind: 'UpdateError', details: result.error, location }, + state, + ); + } + + return { + ...state, + sections: result.value.sections, + statusMessage: undefined, + editing: undefined, + history: history ?? [ + { + kind: 'update', + location, + from: result.value.from, + to: result.value.to, + }, + ...state.history, + ], + }; +}; + +const undo = (state: State): State => { + const [lastEvent, ...rest] = state.history; + + switch (lastEvent?.kind) { + case undefined: + return errorState({ kind: 'NoHistoryError' }, state); + case 'delete': + return insertAction( + state, + lastEvent.location, + lastEvent.section, + rest, + ); + case 'insert': + return deleteAction(state, lastEvent.location, rest); + case 'move': + return moveAction(state, lastEvent.from, lastEvent.distance, rest); + case 'update': + return updateAction( + state, + lastEvent.location, + lastEvent.from.title, + lastEvent.from.path, + rest, + ); + } +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.kind) { + case 'delete': + return deleteAction(state, action.location, undefined); + case 'insert': + return insertAction( + state, + action.location, + action.section, + undefined, + ); + case 'undo': + return undo(state); + case 'reset': { + if (state.history.length === 0) { + return errorState({ kind: 'NoHistoryError' }, state); + } + + return { + ...state, + sections: action.initial, + history: [], + statusMessage: undefined, + }; + } + case 'insertInto': + return { + ...state, + insertingAt: [...action.location, 0], + statusMessage: undefined, + }; + case 'cancelInsert': + return { + ...state, + insertingAt: undefined, + statusMessage: undefined, + }; + case 'moveDown': + return moveAction(state, action.location, 1, undefined); + case 'moveUp': + return moveAction(state, action.location, -1, undefined); + case 'prePublish': + return { ...state, prepublish: true }; + case 'cancelPrePublish': + return { ...state, prepublish: false }; + case 'publishSuccess': + return { + ...state, + statusMessage: ok('Publication successful.'), + prepublish: false, + history: [], + }; + case 'publishError': + return { + ...errorState( + { kind: 'PublishError', details: action.error }, + state, + ), + prepublish: false, + }; + case 'edit': + return { + ...state, + editing: { + title: action.title, + path: action.path, + mobileOverride: action.mobileOverride, + location: action.location, + }, + }; + case 'cancelEdit': + return { ...state, editing: undefined }; + case 'update': + return updateAction( + state, + action.location, + action.title, + action.path, + undefined, + ); + } +}; + +const errorState = (message: AppsNavError, state: State): State => ({ + ...state, + statusMessage: error(message), +}); diff --git a/dotcom-rendering/src/components/InteractiveAtomMessenger.importable.tsx b/dotcom-rendering/src/components/InteractiveAtomMessenger.importable.tsx index 5d2284d68d6..635d948ef42 100644 --- a/dotcom-rendering/src/components/InteractiveAtomMessenger.importable.tsx +++ b/dotcom-rendering/src/components/InteractiveAtomMessenger.importable.tsx @@ -1,13 +1,13 @@ import { log } from '@guardian/libs'; import { useCallback, useEffect, useState } from 'react'; -import type { Output } from 'valibot'; +import type { InferOutput } from 'valibot'; import { literal, number, object, safeParse, variant } from 'valibot'; type Props = { id: string; }; -type InteractiveMessage = Output; +type InteractiveMessage = InferOutput; const interactiveMessageSchema = variant('kind', [ object({ kind: literal('interactive:scroll'), diff --git a/dotcom-rendering/src/lib/discussion.ts b/dotcom-rendering/src/lib/discussion.ts index 0b9afa21c10..9e7ac0d59f3 100644 --- a/dotcom-rendering/src/lib/discussion.ts +++ b/dotcom-rendering/src/lib/discussion.ts @@ -1,15 +1,15 @@ import { isOneOf } from '@guardian/libs'; -import type { BaseSchema, Input, Output } from 'valibot'; +import type { GenericSchema, InferInput, InferOutput } from 'valibot'; import { array, boolean, literal, - merge, minLength, number, object, optional, picklist, + pipe, safeParse, string, transform, @@ -36,7 +36,7 @@ export type CAPIPillar = | 'lifestyle' | 'labs'; -const userProfile: BaseSchema = object({ +const userProfile: GenericSchema = object({ userId: string(), displayName: string(), webUrl: string(), @@ -86,7 +86,10 @@ export const parseUserProfile = ( }; const baseCommentSchema = object({ - id: transform(union([number(), string()]), (id) => id.toString()), + id: pipe( + union([number(), string()]), + transform((id) => id.toString()), + ), body: string(), date: string(), isoDateTime: string(), @@ -118,32 +121,28 @@ const baseCommentSchema = object({ ), }); -export type ReplyType = Output; - -const replySchema = merge([ - baseCommentSchema, - object({ - responses: optional(undefined_(), undefined), - responseTo: object({ - displayName: string(), - commentApiUrl: string(), - isoDateTime: string(), - date: string(), - commentId: string(), - commentWebUrl: string(), - }), +export type ReplyType = InferOutput; + +const replySchema = object({ + ...baseCommentSchema.entries, + responses: optional(undefined_(), undefined), + responseTo: object({ + displayName: string(), + commentApiUrl: string(), + isoDateTime: string(), + date: string(), + commentId: string(), + commentWebUrl: string(), }), -]); +}); -export type CommentType = Output; +export type CommentType = InferOutput; -const commentSchema = merge([ - baseCommentSchema, - object({ - responses: optional(array(replySchema), []), - responseTo: optional(undefined_(), undefined), - }), -]); +const commentSchema = object({ + ...baseCommentSchema.entries, + responses: optional(array(replySchema), []), + responseTo: optional(undefined_(), undefined), +}); const commentRepliesResponseSchema = variant('status', [ object({ @@ -267,11 +266,13 @@ export const parseAbuseResponse = (data: unknown): Result => { export const postUsernameResponseSchema = variant('status', [ object({ status: literal('error'), - errors: array( - object({ - message: string(), - }), - [minLength(1)], + errors: pipe( + array( + object({ + message: string(), + }), + ), + minLength(1), ), }), object({ @@ -324,7 +325,9 @@ const discussionApiErrorSchema = object({ errorCode: optional(string()), }); -export type GetDiscussionSuccess = Output; +export type GetDiscussionSuccess = InferOutput< + typeof discussionApiSuccessSchema +>; const discussionApiSuccessSchema = object({ status: literal('ok'), @@ -351,7 +354,9 @@ export const discussionApiResponseSchema = variant('status', [ discussionApiSuccessSchema, ]); -export type GetDiscussionResponse = Input; +export type GetDiscussionResponse = InferInput< + typeof discussionApiResponseSchema +>; export interface DiscussionOptions { orderBy: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e29261b47e7..ec339dc9136 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,7 +134,7 @@ importers: version: 3.0.6(webpack@5.102.1) '@storybook/react-webpack5': specifier: 9.1.13 - version: 9.1.13(@swc/core@1.13.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.13(@testing-library/dom@10.4.1)(prettier@3.0.3)(vite@6.4.0(@types/node@22.17.0)(terser@5.44.0)(tsx@4.6.2)))(typescript@5.5.3)(webpack-cli@6.0.1) + version: 9.1.13(@swc/core@1.13.5)(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.13(@testing-library/dom@10.4.1)(prettier@3.0.3)(vite@6.4.0(@types/node@22.17.0)(terser@5.44.0)(tsx@4.6.2)))(typescript@5.5.3)(webpack-cli@6.0.1) '@types/clean-css': specifier: 4.2.11 version: 4.2.11 @@ -245,7 +245,7 @@ importers: version: 0.20.0 ts-jest: specifier: 29.1.2 - version: 29.1.2(@babel/core@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest@29.7.0(@types/node@22.17.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.17.0)(typescript@5.5.3)))(typescript@5.5.3) + version: 29.1.2(@babel/core@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.17.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.17.0)(typescript@5.5.3)))(typescript@5.5.3) ts-loader: specifier: 9.5.4 version: 9.5.4(typescript@5.5.3)(webpack@5.102.1) @@ -260,7 +260,7 @@ importers: version: 5.5.3 webpack: specifier: 5.102.1 - version: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) + version: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) webpack-cli: specifier: 6.0.1 version: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) @@ -377,7 +377,7 @@ importers: version: 4.0.1(storybook@9.1.13(@testing-library/dom@10.4.1)(prettier@3.0.3)(vite@6.4.0(@types/node@22.17.0)(terser@5.44.0)(tsx@4.6.2)))(webpack@5.102.1) '@storybook/react-webpack5': specifier: 9.1.13 - version: 9.1.13(@swc/core@1.13.5)(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.13(@testing-library/dom@10.4.1)(prettier@3.0.3)(vite@6.4.0(@types/node@22.17.0)(terser@5.44.0)(tsx@4.6.2)))(typescript@5.5.3)(webpack-cli@6.0.1) + version: 9.1.13(@swc/core@1.13.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.13(@testing-library/dom@10.4.1)(prettier@3.0.3)(vite@6.4.0(@types/node@22.17.0)(terser@5.44.0)(tsx@4.6.2)))(typescript@5.5.3)(webpack-cli@6.0.1) '@svgr/webpack': specifier: 8.1.0 version: 8.1.0(typescript@5.5.3) @@ -482,13 +482,13 @@ importers: version: 0.0.6 '@types/webpack-bundle-analyzer': specifier: 4.7.0 - version: 4.7.0(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + version: 4.7.0(@swc/core@1.13.5)(webpack-cli@6.0.1) '@types/webpack-env': specifier: 1.18.8 version: 1.18.8 '@types/webpack-node-externals': specifier: 3.0.4 - version: 3.0.4(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + version: 3.0.4(@swc/core@1.13.5)(webpack-cli@6.0.1) '@types/youtube': specifier: 0.0.50 version: 0.0.50 @@ -545,10 +545,10 @@ importers: version: 8.57.1 eslint-config-airbnb-base: specifier: 15.0.0 - version: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1))(eslint@8.57.1) + version: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1) eslint-config-airbnb-typescript: specifier: 17.0.0 - version: 17.0.0(@typescript-eslint/eslint-plugin@8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3))(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1))(eslint@8.57.1) + version: 17.0.0(@typescript-eslint/eslint-plugin@8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3))(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-custom-elements: specifier: 0.0.8 version: 0.0.8(eslint@8.57.1) @@ -712,14 +712,14 @@ importers: specifier: 11.0.5 version: 11.0.5 valibot: - specifier: 0.28.1 - version: 0.28.1 + specifier: 1.1.0 + version: 1.1.0(typescript@5.5.3) web-vitals: specifier: 4.2.3 version: 4.2.3 webpack: specifier: 5.102.1 - version: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + version: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) webpack-assets-manifest: specifier: 6.3.0 version: 6.3.0(webpack@5.102.1) @@ -7947,7 +7947,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.13.0: @@ -9159,8 +9158,13 @@ packages: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} - valibot@0.28.1: - resolution: {integrity: sha512-zQnjwNJuXk6362Leu0+4eFa/SMwRom3/hEvH6s1EGf3oXIPbo2WFKDra9ymnbVh3clLRvd8hw4sKF5ruI2Lyvw==} + valibot@1.1.0: + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -12417,12 +12421,12 @@ snapshots: '@guardian/eslint-config-typescript@12.0.0(eslint@8.57.1)(tslib@2.6.2)(typescript@5.5.3)': dependencies: - '@guardian/eslint-config': 9.0.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)(tslib@2.6.2) + '@guardian/eslint-config': 9.0.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)(tslib@2.6.2) '@stylistic/eslint-plugin': 2.6.2(eslint@8.57.1)(typescript@5.5.3) '@typescript-eslint/eslint-plugin': 8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3) '@typescript-eslint/parser': 8.1.0(eslint@8.57.1)(typescript@5.5.3) eslint: 8.57.1 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) tslib: 2.6.2 typescript: 5.5.3 @@ -12431,7 +12435,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - '@guardian/eslint-config@9.0.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)(tslib@2.6.2)': + '@guardian/eslint-config@9.0.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)(tslib@2.6.2)': dependencies: eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) @@ -14572,11 +14576,11 @@ snapshots: '@types/uuid@9.0.8': {} - '@types/webpack-bundle-analyzer@4.7.0(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1)': + '@types/webpack-bundle-analyzer@4.7.0(@swc/core@1.13.5)(webpack-cli@6.0.1)': dependencies: '@types/node': 22.17.0 tapable: 2.2.1 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) transitivePeerDependencies: - '@swc/core' - esbuild @@ -14585,10 +14589,10 @@ snapshots: '@types/webpack-env@1.18.8': {} - '@types/webpack-node-externals@3.0.4(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1)': + '@types/webpack-node-externals@3.0.4(@swc/core@1.13.5)(webpack-cli@6.0.1)': dependencies: '@types/node': 22.17.0 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) transitivePeerDependencies: - '@swc/core' - esbuild @@ -14973,17 +14977,17 @@ snapshots: '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack-dev-server@5.2.2)(webpack@5.102.1)': dependencies: webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) optionalDependencies: webpack-dev-server: 5.2.2(webpack-cli@6.0.1)(webpack@5.102.1) @@ -15337,14 +15341,14 @@ snapshots: dependencies: '@babel/core': 7.28.4 find-up: 5.0.0 - webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) babel-loader@9.2.1(@babel/core@7.28.4)(webpack@5.102.1): dependencies: '@babel/core': 7.28.4 find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) babel-plugin-istanbul@6.1.1: dependencies: @@ -15953,7 +15957,7 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.5.4 optionalDependencies: - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) css-select@4.3.0: dependencies: @@ -16481,7 +16485,7 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: confusing-browser-globals: 1.0.11 eslint: 8.57.1 @@ -16490,12 +16494,12 @@ snapshots: object.entries: 1.1.7 semver: 6.3.1 - eslint-config-airbnb-typescript@17.0.0(@typescript-eslint/eslint-plugin@8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3))(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1))(eslint@8.57.1): + eslint-config-airbnb-typescript@17.0.0(@typescript-eslint/eslint-plugin@8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3))(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@typescript-eslint/eslint-plugin': 8.1.0(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1)(typescript@5.5.3) '@typescript-eslint/parser': 8.1.0(eslint@8.57.1)(typescript@5.5.3) eslint: 8.57.1 - eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1))(eslint@8.57.1) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) eslint-config-prettier@9.1.0(eslint@8.57.1): @@ -16510,12 +16514,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1): dependencies: debug: 4.4.3(supports-color@8.1.1) enhanced-resolve: 5.18.3 eslint: 8.57.1 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.7.2 @@ -16527,14 +16531,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.1.0(eslint@8.57.1)(typescript@5.5.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -16558,7 +16562,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.1.0(eslint@8.57.1)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -20355,7 +20359,7 @@ snapshots: dependencies: '@swc/core': 1.13.5 '@swc/counter': 0.1.3 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) swr@1.3.0(react@18.3.1): dependencies: @@ -20542,7 +20546,7 @@ snapshots: ts-dedent@2.2.0: {} - ts-jest@29.1.2(@babel/core@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest@29.7.0(@types/node@22.17.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.17.0)(typescript@5.5.3)))(typescript@5.5.3): + ts-jest@29.1.2(@babel/core@7.28.4)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.17.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.17.0)(typescript@5.5.3)))(typescript@5.5.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -20558,6 +20562,7 @@ snapshots: '@babel/core': 7.28.4 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) + esbuild: 0.25.5 ts-loader@9.5.4(typescript@5.5.3)(webpack@5.102.1): dependencies: @@ -20859,7 +20864,9 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - valibot@0.28.1: {} + valibot@1.1.0(typescript@5.5.3): + optionalDependencies: + typescript: 5.5.3 validate-npm-package-license@3.0.4: dependencies: @@ -20950,7 +20957,7 @@ snapshots: lockfile: 1.0.4 schema-utils: 4.3.3 tapable: 2.3.0 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) webpack-bundle-analyzer@4.10.2: dependencies: @@ -20984,7 +20991,7 @@ snapshots: import-local: 3.2.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) webpack-merge: 6.0.1 optionalDependencies: webpack-bundle-analyzer: 4.10.2 @@ -21004,7 +21011,7 @@ snapshots: import-local: 3.2.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) webpack-merge: 6.0.1 optionalDependencies: webpack-dev-server: 5.2.2(webpack-cli@6.0.1)(webpack@5.102.1) @@ -21039,7 +21046,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) webpack-dev-server@5.2.2(webpack-cli@6.0.1)(webpack@5.102.1): dependencies: @@ -21073,7 +21080,7 @@ snapshots: ws: 8.18.3 optionalDependencies: webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) transitivePeerDependencies: - bufferutil - debug @@ -21095,7 +21102,7 @@ snapshots: debug: 3.2.7 require-from-string: 2.0.2 source-map-support: 0.5.21 - webpack: 5.102.1(@swc/core@1.13.5)(esbuild@0.25.5)(webpack-cli@6.0.1) + webpack: 5.102.1(@swc/core@1.13.5)(webpack-cli@6.0.1) transitivePeerDependencies: - supports-color @@ -21156,7 +21163,7 @@ snapshots: watchpack: 2.4.4 webpack-sources: 3.3.3 optionalDependencies: - webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) transitivePeerDependencies: - '@swc/core' - esbuild @@ -21190,7 +21197,7 @@ snapshots: watchpack: 2.4.4 webpack-sources: 3.3.3 optionalDependencies: - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack-cli: 6.0.1(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@5.2.2)(webpack@5.102.1) transitivePeerDependencies: - '@swc/core' - esbuild