F: Add zod validation for query params

This commit is contained in:
Ivan 2025-01-30 19:23:15 +03:00
parent 55d7b919c8
commit 2b08a5a966
10 changed files with 88 additions and 54 deletions

View File

@ -45,6 +45,7 @@ This readme file is used mostly to document project dependencies and conventions
- qrcode.react - qrcode.react
- html-to-image - html-to-image
- zustand - zustand
- zod
- @tanstack/react-table - @tanstack/react-table
- @tanstack/react-query - @tanstack/react-query
- @tanstack/react-query-devtools - @tanstack/react-query-devtools

View File

@ -33,6 +33,7 @@
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
@ -10932,7 +10933,6 @@
"version": "3.24.1", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@ -37,6 +37,7 @@
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -24,18 +24,6 @@ export const routes = {
database_schema: 'database-schema' 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. * Internal navigation URLs.
*/ */
@ -58,12 +46,24 @@ export const urls = {
schema: (id: number | string, version?: number | string) => schema: (id: number | string, version?: number | string) =>
`/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''), `/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''),
oss: (id: number | string, tab?: number) => `/oss/${id}` + (tab !== undefined ? `?tab=${tab}` : ''), 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 versionStr = version !== undefined ? `v=${version}&` : '';
const activeStr = active !== undefined ? `&active=${active}` : ''; const activeStr = active !== undefined ? `&active=${active}` : '';
return `/rsforms/${id}?${versionStr}tab=${tab}${activeStr}`; 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}`; return `/oss/${id}?tab=${tab}`;
} }
}; };

View File

@ -19,12 +19,11 @@ import { resources } from '@/utils/constants';
function LoginPage() { function LoginPage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const query = useQueryStrings(); const query = useQueryStrings();
const userQuery = query.get('username');
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const { login, isPending, error, reset } = useLogin(); const { login, isPending, error, reset } = useLogin();
const [username, setUsername] = useState(userQuery || ''); const [username, setUsername] = useState(query.get('username') ?? '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
useEffect(() => { useEffect(() => {

View File

@ -13,7 +13,7 @@ import ViewTopic from './ViewTopic';
export function ManualsPage() { export function ManualsPage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const query = useQueryStrings(); const query = useQueryStrings();
const activeTopic = (query.get('topic') || HelpTopic.MAIN) as HelpTopic; const activeTopic = (query.get('topic') ?? HelpTopic.MAIN) as HelpTopic;
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();

View File

@ -1,35 +1,53 @@
'use client'; 'use client';
import axios from 'axios'; import axios from 'axios';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { z } from 'zod';
import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
import TextURL from '@/components/ui/TextURL'; import TextURL from '@/components/ui/TextURL';
import useQueryStrings from '@/hooks/useQueryStrings';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { OssEditState } from './OssEditContext'; import { OssEditState, OssTabID } from './OssEditContext';
import OssTabs from './OssTabs'; 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() { export function OssPage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const params = useParams(); 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); useBlockNavigation(isModified);
if (!itemID) { useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) {
router.replace(urls.page404); router.replace(urls.page404);
return null; return null;
} }
return ( return (
<ErrorBoundary FallbackComponent={ProcessError}> <ErrorBoundary FallbackComponent={ProcessError}>
<OssEditState itemID={itemID}> <OssEditState itemID={urlData.id}>
<OssTabs /> <OssTabs activeTab={urlData.tab} />
</OssEditState> </OssEditState>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@ -7,28 +7,23 @@ import { TabList, TabPanel, Tabs } from 'react-tabs';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import useQueryStrings from '@/hooks/useQueryStrings';
import { useAppLayoutStore } from '@/stores/appLayout'; import { useAppLayoutStore } from '@/stores/appLayout';
import { useModificationStore } from '@/stores/modification';
import EditorRSForm from './EditorOssCard'; import EditorRSForm from './EditorOssCard';
import EditorTermGraph from './EditorOssGraph'; import EditorTermGraph from './EditorOssGraph';
import MenuOssTabs from './MenuOssTabs'; import MenuOssTabs from './MenuOssTabs';
import { OssTabID, useOssEdit } from './OssEditContext'; import { OssTabID, useOssEdit } from './OssEditContext';
function OssTabs() { interface OssTabsProps {
const router = useConceptNavigation(); activeTab: OssTabID;
const query = useQueryStrings(); }
const activeTab = query.get('tab') ? (Number(query.get('tab')) as OssTabID) : OssTabID.GRAPH;
function OssTabs({ activeTab }: OssTabsProps) {
const router = useConceptNavigation();
const { schema, navigateTab } = useOssEdit(); const { schema, navigateTab } = useOssEdit();
const hideFooter = useAppLayoutStore(state => state.hideFooter); const hideFooter = useAppLayoutStore(state => state.hideFooter);
const { setIsModified } = useModificationStore();
useEffect(() => setIsModified(false), [setIsModified]);
useEffect(() => { useEffect(() => {
const oldTitle = document.title; const oldTitle = document.title;
document.title = schema.title; document.title = schema.title;

View File

@ -1,8 +1,10 @@
'use client'; 'use client';
import axios from 'axios'; import axios from 'axios';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { z } from 'zod';
import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useBlockNavigation, useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
@ -16,29 +18,48 @@ import { useModificationStore } from '@/stores/modification';
import { RSEditState, RSTabID } from './RSEditContext'; import { RSEditState, RSTabID } from './RSEditContext';
import RSTabs from './RSTabs'; 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() { export function RSFormPage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const query = useQueryStrings();
const params = useParams(); const params = useParams();
const itemID = params.id ? Number(params.id) : undefined; const query = useQueryStrings();
const version = query.get('v') ? Number(query.get('v')) : undefined;
const activeTab = query.get('tab') ? (Number(query.get('tab')) as RSTabID) : RSTabID.CARD;
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); useBlockNavigation(isModified);
if (!itemID) { useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) {
router.replace(urls.page404); router.replace(urls.page404);
return null; return null;
} }
return ( return (
<ErrorBoundary <ErrorBoundary
FallbackComponent={({ error }) => ( FallbackComponent={({ error }) => (
<ProcessError error={error as ErrorData} isArchive={!!version} itemID={itemID} /> <ProcessError error={error as ErrorData} isArchive={!!urlData.version} itemID={urlData.id} />
)} )}
> >
<RSEditState itemID={itemID} activeVersion={version} activeTab={activeTab}> <RSEditState itemID={urlData.id} activeVersion={urlData.version} activeTab={urlData.tab}>
<RSTabs /> <RSTabs activeID={urlData.activeID} activeTab={urlData.tab} />
</RSEditState> </RSEditState>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@ -7,7 +7,7 @@ import { TabList, TabPanel, Tabs } from 'react-tabs';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import useQueryStrings from '@/hooks/useQueryStrings'; import { ConstituentaID } from '@/models/rsform';
import { useAppLayoutStore } from '@/stores/appLayout'; import { useAppLayoutStore } from '@/stores/appLayout';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { labelVersion } from '@/utils/labels'; import { labelVersion } from '@/utils/labels';
@ -19,18 +19,18 @@ import EditorTermGraph from './EditorTermGraph';
import MenuRSTabs from './MenuRSTabs'; import MenuRSTabs from './MenuRSTabs';
import { RSTabID, useRSEdit } from './RSEditContext'; import { RSTabID, useRSEdit } from './RSEditContext';
function RSTabs() { interface RSTabsProps {
const query = useQueryStrings(); activeID?: ConstituentaID;
activeTab: RSTabID;
}
function RSTabs({ activeID, activeTab }: RSTabsProps) {
const router = useConceptNavigation(); 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 hideFooter = useAppLayoutStore(state => state.hideFooter);
const { setIsModified } = useModificationStore(); const { setIsModified } = useModificationStore();
const { schema, selected, setSelected, navigateRSForm } = useRSEdit(); const { schema, selected, setSelected, navigateRSForm } = useRSEdit();
useEffect(() => setIsModified(false), [setIsModified]);
useEffect(() => { useEffect(() => {
const oldTitle = document.title; const oldTitle = document.title;
document.title = schema.title; document.title = schema.title;
@ -43,15 +43,14 @@ function RSTabs() {
hideFooter(activeTab !== RSTabID.CARD); hideFooter(activeTab !== RSTabID.CARD);
setIsModified(false); setIsModified(false);
if (activeTab === RSTabID.CST_EDIT) { if (activeTab === RSTabID.CST_EDIT) {
const cstID = Number(cstQuery); if (activeID && schema.cstByID.has(activeID)) {
if (cstID && schema.cstByID.has(cstID)) { setSelected([activeID]);
setSelected([cstID]);
} else { } else {
setSelected([]); setSelected([]);
} }
} }
return () => hideFooter(false); return () => hideFooter(false);
}, [activeTab, cstQuery, setSelected, schema, hideFooter, setIsModified]); }, [activeTab, activeID, setSelected, schema, hideFooter, setIsModified]);
function onSelectTab(index: number, last: number, event: Event) { function onSelectTab(index: number, last: number, event: Event) {
if (last === index) { if (last === index) {