diff --git a/README.md b/README.md index 3a84bd47..f8576165 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This readme file is used mostly to document project dependencies and conventions - qrcode.react - html-to-image - zustand + - zod - @tanstack/react-table - @tanstack/react-query - @tanstack/react-query-devtools diff --git a/rsconcept/frontend/package-lock.json b/rsconcept/frontend/package-lock.json index f5ea912a..774100ff 100644 --- a/rsconcept/frontend/package-lock.json +++ b/rsconcept/frontend/package-lock.json @@ -33,6 +33,7 @@ "react-zoom-pan-pinch": "^3.6.1", "reactflow": "^11.11.4", "use-debounce": "^10.0.4", + "zod": "^3.24.1", "zustand": "^5.0.3" }, "devDependencies": { @@ -10932,7 +10933,6 @@ "version": "3.24.1", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/rsconcept/frontend/package.json b/rsconcept/frontend/package.json index ebedd53c..1b604886 100644 --- a/rsconcept/frontend/package.json +++ b/rsconcept/frontend/package.json @@ -37,6 +37,7 @@ "react-zoom-pan-pinch": "^3.6.1", "reactflow": "^11.11.4", "use-debounce": "^10.0.4", + "zod": "^3.24.1", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/rsconcept/frontend/src/app/urls.ts b/rsconcept/frontend/src/app/urls.ts index d36ef301..a2e93cab 100644 --- a/rsconcept/frontend/src/app/urls.ts +++ b/rsconcept/frontend/src/app/urls.ts @@ -24,18 +24,6 @@ export const routes = { database_schema: 'database-schema' }; -interface SchemaProps { - id: number | string; - tab: number; - version?: number | string; - active?: number | string; -} - -interface OssProps { - id: number | string; - tab: number; -} - /** * Internal navigation URLs. */ @@ -58,12 +46,24 @@ export const urls = { schema: (id: number | string, version?: number | string) => `/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''), oss: (id: number | string, tab?: number) => `/oss/${id}` + (tab !== undefined ? `?tab=${tab}` : ''), - schema_props: ({ id, tab, version, active }: SchemaProps) => { + + schema_props: ({ + id, + tab, + version, + active + }: { + id: number | string; + tab: number; + version?: number | string; + active?: number | string; + }) => { const versionStr = version !== undefined ? `v=${version}&` : ''; const activeStr = active !== undefined ? `&active=${active}` : ''; return `/rsforms/${id}?${versionStr}tab=${tab}${activeStr}`; }, - oss_props: ({ id, tab }: OssProps) => { + + oss_props: ({ id, tab }: { id: number | string; tab: number }) => { return `/oss/${id}?tab=${tab}`; } }; diff --git a/rsconcept/frontend/src/pages/LoginPage.tsx b/rsconcept/frontend/src/pages/LoginPage.tsx index f3f73026..519b93bd 100644 --- a/rsconcept/frontend/src/pages/LoginPage.tsx +++ b/rsconcept/frontend/src/pages/LoginPage.tsx @@ -19,12 +19,11 @@ import { resources } from '@/utils/constants'; function LoginPage() { const router = useConceptNavigation(); const query = useQueryStrings(); - const userQuery = query.get('username'); const { isAnonymous } = useAuthSuspense(); const { login, isPending, error, reset } = useLogin(); - const [username, setUsername] = useState(userQuery || ''); + const [username, setUsername] = useState(query.get('username') ?? ''); const [password, setPassword] = useState(''); useEffect(() => { diff --git a/rsconcept/frontend/src/pages/ManualsPage/ManualsPage.tsx b/rsconcept/frontend/src/pages/ManualsPage/ManualsPage.tsx index 8de7bbb0..8f1e50e7 100644 --- a/rsconcept/frontend/src/pages/ManualsPage/ManualsPage.tsx +++ b/rsconcept/frontend/src/pages/ManualsPage/ManualsPage.tsx @@ -13,7 +13,7 @@ import ViewTopic from './ViewTopic'; export function ManualsPage() { const router = useConceptNavigation(); const query = useQueryStrings(); - const activeTopic = (query.get('topic') || HelpTopic.MAIN) as HelpTopic; + const activeTopic = (query.get('topic') ?? HelpTopic.MAIN) as HelpTopic; const mainHeight = useMainHeight(); diff --git a/rsconcept/frontend/src/pages/OssPage/OssPage.tsx b/rsconcept/frontend/src/pages/OssPage/OssPage.tsx index 30bebf4f..1ddb95e8 100644 --- a/rsconcept/frontend/src/pages/OssPage/OssPage.tsx +++ b/rsconcept/frontend/src/pages/OssPage/OssPage.tsx @@ -1,35 +1,53 @@ 'use client'; import axios from 'axios'; +import { useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useParams } from 'react-router'; +import { z } from 'zod'; import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { urls } from '@/app/urls'; import { ErrorData } from '@/components/info/InfoError'; import TextURL from '@/components/ui/TextURL'; +import useQueryStrings from '@/hooks/useQueryStrings'; import { useModificationStore } from '@/stores/modification'; -import { OssEditState } from './OssEditContext'; +import { OssEditState, OssTabID } from './OssEditContext'; import OssTabs from './OssTabs'; +const paramsSchema = z.object({ + id: z + .string() + .nullish() + .transform(v => (v ? Number(v) : undefined)), + tab: z.preprocess(v => (v ? Number(v) : undefined), z.nativeEnum(OssTabID).default(OssTabID.CARD)) +}); + export function OssPage() { const router = useConceptNavigation(); const params = useParams(); - const itemID = params.id ? Number(params.id) : undefined; + const query = useQueryStrings(); - const { isModified } = useModificationStore(); + const urlData = paramsSchema.parse({ + id: params.id, + tab: query.get('tab') + }); + + const { isModified, setIsModified } = useModificationStore(); useBlockNavigation(isModified); - if (!itemID) { + useEffect(() => setIsModified(false), [setIsModified]); + + if (!urlData.id) { router.replace(urls.page404); return null; } return ( - - + + ); diff --git a/rsconcept/frontend/src/pages/OssPage/OssTabs.tsx b/rsconcept/frontend/src/pages/OssPage/OssTabs.tsx index e0bbc9c6..6dd3eef7 100644 --- a/rsconcept/frontend/src/pages/OssPage/OssTabs.tsx +++ b/rsconcept/frontend/src/pages/OssPage/OssTabs.tsx @@ -7,28 +7,23 @@ import { TabList, TabPanel, Tabs } from 'react-tabs'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import Overlay from '@/components/ui/Overlay'; import TabLabel from '@/components/ui/TabLabel'; -import useQueryStrings from '@/hooks/useQueryStrings'; import { useAppLayoutStore } from '@/stores/appLayout'; -import { useModificationStore } from '@/stores/modification'; import EditorRSForm from './EditorOssCard'; import EditorTermGraph from './EditorOssGraph'; import MenuOssTabs from './MenuOssTabs'; import { OssTabID, useOssEdit } from './OssEditContext'; -function OssTabs() { - const router = useConceptNavigation(); - const query = useQueryStrings(); - const activeTab = query.get('tab') ? (Number(query.get('tab')) as OssTabID) : OssTabID.GRAPH; +interface OssTabsProps { + activeTab: OssTabID; +} +function OssTabs({ activeTab }: OssTabsProps) { + const router = useConceptNavigation(); const { schema, navigateTab } = useOssEdit(); const hideFooter = useAppLayoutStore(state => state.hideFooter); - const { setIsModified } = useModificationStore(); - - useEffect(() => setIsModified(false), [setIsModified]); - useEffect(() => { const oldTitle = document.title; document.title = schema.title; diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSFormPage.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSFormPage.tsx index 39285576..0d83bb77 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSFormPage.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSFormPage.tsx @@ -1,8 +1,10 @@ 'use client'; import axios from 'axios'; +import { useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useParams } from 'react-router'; +import { z } from 'zod'; import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { urls } from '@/app/urls'; @@ -16,29 +18,48 @@ import { useModificationStore } from '@/stores/modification'; import { RSEditState, RSTabID } from './RSEditContext'; import RSTabs from './RSTabs'; +const paramsSchema = z.object({ + id: z + .string() + .nullish() + .transform(v => (v ? Number(v) : undefined)), + version: z + .string() + .nullish() + .transform(v => (v ? Number(v) : undefined)), + tab: z.preprocess(v => (v ? Number(v) : undefined), z.nativeEnum(RSTabID).default(RSTabID.CARD)), + activeID: z.preprocess(v => (v ? Number(v) : undefined), z.number().optional()) +}); + export function RSFormPage() { const router = useConceptNavigation(); - const query = useQueryStrings(); const params = useParams(); - const itemID = params.id ? Number(params.id) : undefined; - const version = query.get('v') ? Number(query.get('v')) : undefined; - const activeTab = query.get('tab') ? (Number(query.get('tab')) as RSTabID) : RSTabID.CARD; + const query = useQueryStrings(); - const { isModified } = useModificationStore(); + const urlData = paramsSchema.parse({ + id: params.id, + version: query.get('v'), + tab: query.get('tab'), + activeID: query.get('active') + }); + + const { isModified, setIsModified } = useModificationStore(); useBlockNavigation(isModified); - if (!itemID) { + useEffect(() => setIsModified(false), [setIsModified]); + + if (!urlData.id) { router.replace(urls.page404); return null; } return ( ( - + )} > - - + + ); diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx index 7866ec02..6770c98d 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx @@ -7,7 +7,7 @@ import { TabList, TabPanel, Tabs } from 'react-tabs'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import Overlay from '@/components/ui/Overlay'; import TabLabel from '@/components/ui/TabLabel'; -import useQueryStrings from '@/hooks/useQueryStrings'; +import { ConstituentaID } from '@/models/rsform'; import { useAppLayoutStore } from '@/stores/appLayout'; import { useModificationStore } from '@/stores/modification'; import { labelVersion } from '@/utils/labels'; @@ -19,18 +19,18 @@ import EditorTermGraph from './EditorTermGraph'; import MenuRSTabs from './MenuRSTabs'; import { RSTabID, useRSEdit } from './RSEditContext'; -function RSTabs() { - const query = useQueryStrings(); +interface RSTabsProps { + activeID?: ConstituentaID; + activeTab: RSTabID; +} + +function RSTabs({ activeID, activeTab }: RSTabsProps) { const router = useConceptNavigation(); - const activeTab = query.get('tab') ? (Number(query.get('tab')) as RSTabID) : RSTabID.CARD; - const cstQuery = query.get('active'); const hideFooter = useAppLayoutStore(state => state.hideFooter); const { setIsModified } = useModificationStore(); const { schema, selected, setSelected, navigateRSForm } = useRSEdit(); - useEffect(() => setIsModified(false), [setIsModified]); - useEffect(() => { const oldTitle = document.title; document.title = schema.title; @@ -43,15 +43,14 @@ function RSTabs() { hideFooter(activeTab !== RSTabID.CARD); setIsModified(false); if (activeTab === RSTabID.CST_EDIT) { - const cstID = Number(cstQuery); - if (cstID && schema.cstByID.has(cstID)) { - setSelected([cstID]); + if (activeID && schema.cstByID.has(activeID)) { + setSelected([activeID]); } else { setSelected([]); } } return () => hideFooter(false); - }, [activeTab, cstQuery, setSelected, schema, hideFooter, setIsModified]); + }, [activeTab, activeID, setSelected, schema, hideFooter, setIsModified]); function onSelectTab(index: number, last: number, event: Event) { if (last === index) {