diff --git a/docs/swr-loading-migration.md b/docs/swr-loading-migration.md new file mode 100644 index 0000000..0fc714e --- /dev/null +++ b/docs/swr-loading-migration.md @@ -0,0 +1,122 @@ +# Moving data loading to SWR + +## Where we are + +The frontend fetches data by hand. Each screen has a `useEffect` that calls an +API function, stores the result in `useState`, and tracks its own `loading` +flag. The guards look like this: + +```ts +useEffect(() => { + if (authenticated && token && activeWorkspaceId) { + // fetch, set state, set loading false + } +}, [authenticated, token, activeWorkspaceId]); +``` + +It works, but every screen repeats the same wiring, and "refresh on workspace +switch" is done manually. There's no shared cache, so two screens asking for the +same thing fetch it twice. + +The loading UI is already standard: one `CenteredProgress` component (a centered +spinner, `role="status"`, screen-reader label, no visible text). Tables take a +`loading` prop. None of that changes here — SWR just decides when `loading` is +true. + +## Where we're going + +Use [SWR](https://swr.vercel.app/) to do the fetching. We picked SWR over +TanStack Query because it's smaller and its conditional-key feature lines up +with how we gate on auth and workspace. + +The key idea: a request key that's `null` means "not ready, don't fetch." So the +auth/workspace guard becomes part of the key instead of an `if`: + +```ts +const key = token && workspaceId ? ["forms", workspaceId] : null; +const { data, isLoading } = useSWR(key, () => getSobaForms(token, workspaceId)); +``` + +When `workspaceId` changes, the key changes, and SWR refetches on its own. That +removes the manual refetch effects. + +## Plan + +Do it one screen at a time. Each phase is shippable on its own. + +### Phase B — set up SWR, migrate one screen + +1. Add the `swr` dependency. +2. Wrap the app in `` (in `AppProviders`) with the default options we + want (e.g. don't revalidate on window focus unless we decide to). +3. Write a small `useAuthedSWR` hook that reads the token and `activeWorkspaceId` + from the store, builds the key (or `null`), and calls the API function. It + returns SWR's `data`, `isLoading`, `isValidating`, `error`, and `mutate`. +4. Migrate `FormList` to it. Delete the `useEffect`, the `useState`, and the + guard. Feed `isLoading` to the table's `loading` prop. +5. Update the `FormList` test to render inside an `` that resets the + cache (see Testing below). + +After this, FormList is the reference. The rest copy it. + +### Phase C — migrate the other reads + +One per change, same shape as FormList: + +- `SubmissionList` +- `SubmissionView` +- `Header` (workspaces, current user) +- the designer's schema/version loads + +Each migration deletes a `useEffect` and its guard. Workspace-switch refetch +effects go away because the key handles it. + +### Phase D — writes and Redux cleanup + +- Move the create/save/publish calls to `useSWRMutation`, and call `mutate` to + refresh the affected list after a write. +- Once a slice is only holding fetched data, delete it. `currentUserSlice` and + the list part of `workspaceSlice` are the candidates. +- Keep in Redux what's actually client state: the Keycloak token and auth flags, + the selected `activeWorkspaceId`, and notifications. + +### Phase E — optional + +Add Next's `loading.tsx` files for route transitions, reusing `CenteredProgress`. +Only worth it if the route-change flash bothers us. + +## How loading maps + +| State | What the user sees | +| --------------------------------------------------------- | ------------------------------------------------- | +| `isLoading` (first load, no data yet) | `CenteredProgress` / table `loading` | +| `isValidating` (background refresh, data already showing) | leave the data up; optionally a small inline hint | +| `error` | the existing inline alert / empty state | + +Don't show the full spinner on a background refresh — that's the point of +splitting `isLoading` from `isValidating`. + +## Testing + +SWR caches across renders, so tests need a fresh cache each time or they leak +into each other. Wrap the component under test: + +```tsx +import { SWRConfig } from "swr"; + +render( + new Map(), dedupingInterval: 0 }}> + + , +); +``` + +The fetch functions are already mocked the same way they are today, so the +assertions don't change much — you're just driving them through SWR. + +## Notes + +- These are client-side reads, same as now. SWR runs in the browser; there's no + server-rendering change. +- Don't touch the Form.io engine. This is about how SOBA loads its own data. +- Token stays in Redux. The hook reads it for the key; SWR never owns auth. diff --git a/frontend/app/[lang]/designer/[...formId]/page.tsx b/frontend/app/[lang]/designer/[...formId]/page.tsx index 9f1398e..ccc7fac 100644 --- a/frontend/app/[lang]/designer/[...formId]/page.tsx +++ b/frontend/app/[lang]/designer/[...formId]/page.tsx @@ -1,5 +1,6 @@ import { getDictionary, hasLocale, Locale } from '../../dictionaries'; import FormDesignerLoader from '@/src/features/designer/ui/FormDesignerLoader'; +import { DsPageHeading } from '@/app/ui/DsPageHeading'; import { notFound } from 'next/navigation'; import { loadFeaturesMeta } from '@/src/shared/config/featuresMeta'; import { createIsFeatureAllowed, FEATURE_CODES } from '@/src/shared/featureFlags/flags'; @@ -28,10 +29,14 @@ export default async function Page({ params }: PageProps) { notFound(); } - const { formId } = await params; + const { lang, formId } = await params; + const dict = await getDictionary((hasLocale(lang) ? lang : 'en') as Locale); return (
+ + {dict.general.formDesigner} +
); diff --git a/frontend/app/[lang]/designer/page.tsx b/frontend/app/[lang]/designer/page.tsx index 733d464..59a0fdf 100644 --- a/frontend/app/[lang]/designer/page.tsx +++ b/frontend/app/[lang]/designer/page.tsx @@ -1,5 +1,6 @@ import { getDictionary, hasLocale, Locale } from '../dictionaries'; import FormDesignerLoader from '@/src/features/designer/ui/FormDesignerLoader'; +import { DsPageHeading } from '@/app/ui/DsPageHeading'; import { notFound } from 'next/navigation'; import { loadFeaturesMeta } from '@/src/shared/config/featuresMeta'; import { createIsFeatureAllowed, FEATURE_CODES } from '@/src/shared/featureFlags/flags'; @@ -20,7 +21,6 @@ export async function generateMetadata({ params }: PageProps) { }; } -// eslint-disable-next-line @typescript-eslint/no-unused-vars export default async function Page({ params }: PageProps) { const featuresMeta = await loadFeaturesMeta(); const isFeatureAllowed = createIsFeatureAllowed(featuresMeta); @@ -28,8 +28,14 @@ export default async function Page({ params }: PageProps) { notFound(); } + const { lang } = await params; + const dict = await getDictionary((hasLocale(lang) ? lang : 'en') as Locale); + return (
+ + {dict.general.formDesigner} +
); diff --git a/frontend/app/[lang]/feedback/page.tsx b/frontend/app/[lang]/feedback/page.tsx index 57472e4..6e8a7f5 100644 --- a/frontend/app/[lang]/feedback/page.tsx +++ b/frontend/app/[lang]/feedback/page.tsx @@ -1,4 +1,5 @@ import { getDictionary, resolveLocale } from '../dictionaries'; +import { DsPageHeading } from '@/app/ui/DsPageHeading'; type PageProps = { params: Promise<{ lang: string }>; @@ -8,7 +9,7 @@ export async function generateMetadata({ params }: PageProps) { const locale = resolveLocale(param.lang); const dict = await getDictionary(locale); return { - title: `${dict.general.title}`, + title: `${dict.general.feedback} | ${dict.general.title}`, description: dict.general.description, }; } @@ -18,5 +19,12 @@ export default async function Page({ params }: PageProps) { const locale = resolveLocale(param.lang); const dict = await getDictionary(locale); - return
{dict.general.feedback}
; + return ( +
+ {dict.general.feedback} +

+ {dict.general.comingSoon} +

+
+ ); } diff --git a/frontend/app/[lang]/forms/page.tsx b/frontend/app/[lang]/forms/page.tsx index 220898a..62236b7 100644 --- a/frontend/app/[lang]/forms/page.tsx +++ b/frontend/app/[lang]/forms/page.tsx @@ -26,12 +26,12 @@ export default async function Page({ params }: PageProps) { const locale = resolveLocale(param.lang); return ( -
+
-
+
); } diff --git a/frontend/app/[lang]/help/page.tsx b/frontend/app/[lang]/help/page.tsx index e44580f..3ba84c6 100644 --- a/frontend/app/[lang]/help/page.tsx +++ b/frontend/app/[lang]/help/page.tsx @@ -1,4 +1,5 @@ import { getDictionary, resolveLocale } from '../dictionaries'; +import { DsPageHeading } from '@/app/ui/DsPageHeading'; type PageProps = { params: Promise<{ lang: string }>; @@ -8,7 +9,7 @@ export async function generateMetadata({ params }: PageProps) { const locale = resolveLocale(param.lang); const dict = await getDictionary(locale); return { - title: `${dict.general.title}`, + title: `${dict.general.help} | ${dict.general.title}`, description: dict.general.description, }; } @@ -18,5 +19,12 @@ export default async function Page({ params }: PageProps) { const locale = resolveLocale(param.lang); const dict = await getDictionary(locale); - return
{dict.general.help}
; + return ( +
+ {dict.general.help} +

+ {dict.general.comingSoon} +

+
+ ); } diff --git a/frontend/app/[lang]/meta/page.tsx b/frontend/app/[lang]/meta/page.tsx deleted file mode 100644 index 6ed5193..0000000 --- a/frontend/app/[lang]/meta/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { DsPageHeading } from '@/app/ui/DsPageHeading'; -import MetaReviewClientLoader from '@/src/features/meta-review/ui/MetaReviewClientLoader'; -import { getDictionary, hasLocale, Locale, resolveLocale } from '../dictionaries'; - -type PageProps = { - params: Promise<{ lang: string }>; -}; - -export async function generateMetadata({ params }: PageProps) { - const param = await params; - const locale = resolveLocale(param.lang); - const dict = await getDictionary(locale); - const title = dict.metaPage?.title ?? 'Meta'; - return { - title: `${title} | ${dict.general.title}`, - description: dict.general.description, - }; -} - -export default async function Page({ params }: PageProps) { - const { lang } = await params; - const locale = hasLocale(lang) ? lang : 'en'; - const dict = await getDictionary(locale as Locale); - const heading = dict.metaPage?.title ?? 'Meta'; - return ( -
- {heading} -
- -
-
- ); -} diff --git a/frontend/app/[lang]/submission/[submissionId]/page.tsx b/frontend/app/[lang]/submission/[submissionId]/page.tsx index ae8850c..1117ad0 100644 --- a/frontend/app/[lang]/submission/[submissionId]/page.tsx +++ b/frontend/app/[lang]/submission/[submissionId]/page.tsx @@ -1,4 +1,5 @@ import { SubmissionView } from '@/src/features/submit-mode/ui/SubmissionView'; +import { DsPageHeading } from '@/app/ui/DsPageHeading'; import { getDictionary, hasLocale, Locale } from '../../dictionaries'; import { notFound } from 'next/navigation'; import { loadFeaturesMeta } from '@/src/shared/config/featuresMeta'; @@ -15,7 +16,7 @@ export async function generateMetadata({ params }: PageProps) { } const dict = await getDictionary(param.lang as Locale); return { - title: `Submission | ${dict.general.title}`, + title: `${dict.submission.pageTitle} | ${dict.general.title}`, description: dict.general.description, }; } @@ -27,9 +28,11 @@ export default async function Page({ params }: PageProps) { notFound(); } - await params; + const { lang } = await params; + const dict = await getDictionary((hasLocale(lang) ? lang : 'en') as Locale); return (
+ {dict.submission.pageTitle}
); diff --git a/frontend/app/ui/Header.module.css b/frontend/app/ui/Header.module.css index f2dda33..c07a240 100644 --- a/frontend/app/ui/Header.module.css +++ b/frontend/app/ui/Header.module.css @@ -29,6 +29,13 @@ border-color: var(--app-border) !important; } +.userDrop:focus-visible { + /* Keyboard focus needs a clearly visible ring; the surface-pinning rules + above otherwise leave focus indistinguishable from the resting state. */ + outline: 2px solid var(--app-focus-ring) !important; + outline-offset: 2px; +} + .limitText { max-width: 100px; /* overflow: hidden; */ diff --git a/frontend/app/ui/Header.tsx b/frontend/app/ui/Header.tsx index 0d5c62e..a012fad 100644 --- a/frontend/app/ui/Header.tsx +++ b/frontend/app/ui/Header.tsx @@ -132,7 +132,7 @@ function Header({ headerNavItems }: HeaderProps) { {displayName ? ( - + diff --git a/frontend/app/ui/base/CenteredProgress.tsx b/frontend/app/ui/base/CenteredProgress.tsx new file mode 100644 index 0000000..afa47e1 --- /dev/null +++ b/frontend/app/ui/base/CenteredProgress.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ProgressCircle } from '@bcgov/design-system-react-components'; + +type CenteredProgressProps = { + /** + * Screen-reader-only label for the spinner. There is no visible loading text + * by design, so this is the only thing assistive tech announces — keep it + * meaningful (e.g. the localized "Loading…"). + */ + label?: string; + /** Optional minimum height so the spinner can center within a tall empty area. */ + minHeight?: string; + 'data-testid'?: string; +}; + +/** + * The single loading indicator for the app: a horizontally/vertically centered + * ProgressCircle with no visible text. Use everywhere a screen, page data area, + * or table body is pending so loading looks and behaves the same throughout. + * + * Accessibility: the wrapper is a `role="status"` live region (implicit + * `aria-live="polite"`) so screen readers announce the spinner when it appears, + * and the ProgressCircle carries an `aria-label` as its accessible name. + */ +export function CenteredProgress({ + label = 'Loading', + minHeight, + 'data-testid': testId = 'loading-indicator', +}: CenteredProgressProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/dictionaries/en.json b/frontend/dictionaries/en.json index ddd62a7..0189ce0 100644 --- a/frontend/dictionaries/en.json +++ b/frontend/dictionaries/en.json @@ -8,16 +8,17 @@ "description": "Next Gen Forms", "notAuthenticated": "You are not authenticated. Please login to access this section.", "forms": "Forms", + "formDesigner": "Form Designer", "feedback": "Feedback", "help": "Help", "welcomeTo": "Welcome to", - "loading": "Loading…" + "loading": "Loading…", + "comingSoon": "This feature is coming soon." }, "header": { "workspaces": "Workspaces", "design": "Design", "submit": "Submit", - "metaReview": "API meta", "themeToggle": "Toggle color theme", "skipToMain": "Skip to main content", "designer": "Designer", @@ -27,23 +28,6 @@ "openMenuAria": "Open navigation menu", "closeMenuAria": "Close navigation menu" }, - "metaPage": { - "title": "API meta & health", - "refresh": "Refresh", - "loadCodes": "Load reference codes", - "loadRoles": "Load roles", - "sections": { - "health": "Health", - "readiness": "Readiness", - "build": "Build", - "features": "Features", - "frontendConfig": "Frontend config", - "plugins": "Plugins", - "formEngines": "Form engines", - "codes": "Reference codes", - "roles": "Roles" - } - }, "base": { "copyToClipboard": { "copyToClipboard": "Copy to Clipboard", @@ -107,8 +91,6 @@ "namePlaceholder": "Enter form name", "slugLabel": "Form Slug", "slugPlaceholder": "Enter form slug", - "descriptionLabel": "Description", - "descriptionPlaceholder": "Enter form description", "loading": "Loading form...", "schemaNotAvailable": "Form schema not available.", "edit": "Edit", @@ -119,11 +101,6 @@ "visibilityPublic": "Public", "visibilityAzureIDIR": "IDIR - MFA", "disclaimerLabel": "I agree to the disclaimer and statement of responsibility", - "disclaimerTitle": "Disclaimer", - "disclaimerText1": "It is your responsibility to comply with Privacy laws governing the collection, use and disclosure of personally identifiable information.", - "disclaimerText2": "Access to this form designer tool does not inherently grant permission to collect, use or disclose any personally identifiable information.", - "disclaimerText3": "It is your responsibility to obtain consent to collect information as required by law.", - "disclaimerText4": "If you use BCeID or BC Services Card as form access options, you MUST notify the Identity Information Management (IDIM) team by email (IDIM.Consulting@gov.bc.ca) your intent to leverage BCeID or BC Services Card.", "exportJson": "Export JSON", "importJson": "Import JSON", "importSchema": "Import Schema", @@ -135,9 +112,6 @@ "designerTab": "Designer", "settingsTab": "Settings", "teamTab": "Team", - "settingsPlaceholder": "Form settings will be available here.", - "teamManagement": "Team Management", - "teamPlaceholder": "Team management tools will be available here.", "readOnlyMode": "Read-Only Mode:", "viewingHistoricalVersion": "You are viewing historical version", "savePublishDisabled": "Save and Publish options are disabled.", @@ -161,12 +135,11 @@ "formPreview": "Form Preview", "untitledForm": "Untitled Form", "closePreview": "Close Preview", - "noFormLayout": "No form layout designed yet.", - "apiKey": "API Key", - "apiKeyPlaceholder": "Enter your api key" + "noFormLayout": "No form layout designed yet." }, "submission": { "submissions": "Submissions", + "pageTitle": "Submission", "loading": "Loading submissions…", "empty": "No submissions in this workspace yet.", "error": "Could not load submissions.", diff --git a/frontend/dictionaries/fr.json b/frontend/dictionaries/fr.json index a08b419..4023469 100644 --- a/frontend/dictionaries/fr.json +++ b/frontend/dictionaries/fr.json @@ -8,16 +8,17 @@ "description": "Formulaires de Nouvelle Génération", "notAuthenticated": "Vous n'êtes pas authentifié. Veuillez vous connecter pour accéder à cette section.", "forms": "Formulaires", + "formDesigner": "Concepteur de formulaires", "feedback": "Retour d'information", "help": "Aidé", "welcomeTo": "Bienvenue sur", - "loading": "Chargement…" + "loading": "Chargement…", + "comingSoon": "Cette fonctionnalité sera bientôt disponible." }, "header": { "workspaces": "Espaces de travail", "design": "Conception", "submit": "Soumettre", - "metaReview": "Méta API", "themeToggle": "Basculer le theme de couleur", "skipToMain": "Aller au contenu principal", "designer": "Générateur de Formulaire", @@ -27,23 +28,6 @@ "openMenuAria": "Ouvrir le menu de navigation", "closeMenuAria": "Fermer le menu de navigation" }, - "metaPage": { - "title": "Méta API et santé", - "refresh": "Actualiser", - "loadCodes": "Charger les codes de référence", - "loadRoles": "Charger les rôles", - "sections": { - "health": "Santé", - "readiness": "Disponibilité", - "build": "Build", - "features": "Fonctionnalités", - "frontendConfig": "Config frontale", - "plugins": "Plugins", - "formEngines": "Moteurs de formulaire", - "codes": "Codes de référence", - "roles": "Rôles" - } - }, "base": { "copyToClipboard": { "copyToClipboard": "Copier dans le presse-papiers", @@ -107,8 +91,6 @@ "namePlaceholder": "Entrez le nom du formulaire", "slugLabel": "Slug du Formulaire", "slugPlaceholder": "Entrez le slug du formulaire", - "descriptionLabel": "Description", - "descriptionPlaceholder": "Entrez la description du formulaire", "loading": "Chargement du formulaire...", "schemaNotAvailable": "Schéma du formulaire non disponible.", "edit": "Modifier", @@ -119,11 +101,6 @@ "visibilityPublic": "Publique", "visibilityAzureIDIR": "IDIR - MFA", "disclaimerLabel": "J'accepte l'avis de non-responsabilité et la déclaration de responsabilité", - "disclaimerTitle": "Avis de non-responsabilité", - "disclaimerText1": "Il est de votre responsabilité de vous conformer aux lois sur la protection de la vie privée régissant la collecte, l'utilisation et la divulgation d'informations personnellement identifiables.", - "disclaimerText2": "L'accès à cet outil de conception de formulaires n'accorde pas intrinsèquement l'autorisation de collecter, d'utiliser ou de divulguer des informations personnellement identifiables.", - "disclaimerText3": "Il est de votre responsabilité d'obtenir le consentement pour recueillir des informations comme l'exige la loi.", - "disclaimerText4": "Si vous utilisez BCeID ou BC Services Card comme options d'accès au formulaire, vous DEVEZ aviser l'équipe de gestion des informations d'identité (IDIM) par courriel (IDIM.Consulting@gov.bc.ca) de votre intention d'utiliser BCeID ou BC Services Card.", "exportJson": "Exporter JSON", "importJson": "Importer JSON", "importSchema": "Importer le schéma", @@ -135,9 +112,6 @@ "designerTab": "Concepteur", "settingsTab": "Paramètres", "teamTab": "Équipe", - "settingsPlaceholder": "Les paramètres du formulaire seront disponibles ici.", - "teamManagement": "Gestion d'équipe", - "teamPlaceholder": "Les outils de gestion d'équipe seront disponibles ici.", "readOnlyMode": "Mode lecture seule :", "viewingHistoricalVersion": "Vous consultez la version historique", "savePublishDisabled": "Les options Sauvegarder et Publier sont désactivées.", @@ -161,12 +135,11 @@ "formPreview": "Aperçu du formulaire", "untitledForm": "Formulaire sans titre", "closePreview": "Fermer l'aperçu", - "noFormLayout": "Aucune mise en page de formulaire n'a encore été conçue.", - "apiKey": "Clé API", - "apiKeyPlaceholder": "Entrez votre clé API" + "noFormLayout": "Aucune mise en page de formulaire n'a encore été conçue." }, "submission": { "submissions": "Soumissions", + "pageTitle": "Soumission", "loading": "Chargement des soumissions…", "empty": "Aucune soumission dans cet espace pour l’instant.", "error": "Impossible de charger les soumissions.", diff --git a/frontend/eslint.config.mts b/frontend/eslint.config.mts deleted file mode 100644 index 6c2f3e2..0000000 --- a/frontend/eslint.config.mts +++ /dev/null @@ -1,51 +0,0 @@ -import js from '@eslint/js'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; -import react from 'eslint-plugin-react'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import prettier from 'eslint-plugin-prettier'; -import { defineConfig, globalIgnores } from 'eslint/config'; - -export default defineConfig([ - globalIgnores(['dist', 'node_modules']), - - { - files: ['**/*.{ts,tsx,js,jsx}'], - - languageOptions: { - ecmaVersion: 2020, - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - }, - }, - - plugins: { - react, - 'react-refresh': reactRefresh, - prettier, - }, - - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - react.configs.flat.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - 'plugin:prettier/recommended', - ], - - rules: { - 'prettier/prettier': 'error', - 'react/react-in-jsx-scope': 'off', - }, - - settings: { - react: { - version: 'detect', - }, - }, - }, -]); diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts deleted file mode 100644 index e0b8946..0000000 --- a/frontend/lib/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { NotificationType } from './slices/notificationSlice'; - -export const NotificationTypes: Record = { - INFO: { type: 'info' }, - SUCCESS: { type: 'success' }, - WARNING: { type: 'warning' }, - ERROR: { type: 'error' }, -}; diff --git a/frontend/lib/runtimeConfig.ts b/frontend/lib/runtimeConfig.ts deleted file mode 100644 index ab487a4..0000000 --- a/frontend/lib/runtimeConfig.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/src/shared/config/runtimeConfig'; diff --git a/frontend/lib/slices/keycloakSlice.ts b/frontend/lib/slices/keycloakSlice.ts index 817f0b7..95020c7 100644 --- a/frontend/lib/slices/keycloakSlice.ts +++ b/frontend/lib/slices/keycloakSlice.ts @@ -2,7 +2,7 @@ import Keycloak from 'keycloak-js'; import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import type { AppDispatch } from '../store'; -import { loadFrontendRuntimeConfig } from '../runtimeConfig'; +import { loadFrontendRuntimeConfig } from '@/src/shared/config/runtimeConfig'; // Keep the Keycloak instance out of Redux state (non-serializable). // Store it in a module-level variable instead. diff --git a/frontend/lib/sobaApi.ts b/frontend/lib/sobaApi.ts deleted file mode 100644 index e7036cf..0000000 --- a/frontend/lib/sobaApi.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/src/shared/api/sobaApi'; diff --git a/frontend/src/app/plugins/registry.ts b/frontend/src/app/plugins/registry.ts index edfd37f..93ba24f 100644 --- a/frontend/src/app/plugins/registry.ts +++ b/frontend/src/app/plugins/registry.ts @@ -1,16 +1,10 @@ import type { Locale } from '@/app/[lang]/dictionaries'; import { designerPlugin } from '@/src/features/designer/plugin'; -import { metaReviewPlugin } from '@/src/features/meta-review/plugin'; import { submitModePlugin } from '@/src/features/submit-mode/plugin'; import { workspacesPlugin } from '@/src/features/workspaces/plugin'; import type { AppPlugin, PluginNavItem, Dictionary } from '@/src/types/plugins'; -const allPlugins: AppPlugin[] = [ - workspacesPlugin, - designerPlugin, - submitModePlugin, - metaReviewPlugin, -]; +const allPlugins: AppPlugin[] = [workspacesPlugin, designerPlugin, submitModePlugin]; function getEnabledPlugins(isFeatureAllowed: (code: string) => boolean): AppPlugin[] { return allPlugins diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx index 052f9db..fae65b9 100644 --- a/frontend/src/app/providers/AppProviders.tsx +++ b/frontend/src/app/providers/AppProviders.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Provider } from 'react-redux'; import { I18nProvider } from 'react-aria-components'; import makeStore from '@/lib/store'; @@ -22,6 +22,15 @@ export default function AppProviders({ }) { const store = useMemo(() => makeStore(), []); + // The root layout renders a static `` (it sits above the + // `[lang]` segment and can't know the locale). Keep the document language in + // sync with the active locale so assistive tech announces `/fr` pages in French. + useEffect(() => { + if (locale) { + document.documentElement.lang = locale; + } + }, [locale]); + return ( diff --git a/frontend/src/app/ui/AuthRedirect.tsx b/frontend/src/app/ui/AuthRedirect.tsx index 2125664..fc70f1f 100644 --- a/frontend/src/app/ui/AuthRedirect.tsx +++ b/frontend/src/app/ui/AuthRedirect.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useKeycloak } from '@/lib/hooks/useKeycloak'; import { useDictionary } from '@/app/[lang]/Providers'; -import { ProgressCircle } from '@bcgov/design-system-react-components'; +import { CenteredProgress } from '@/app/ui/base/CenteredProgress'; export function AuthRedirect({ to, @@ -28,14 +28,7 @@ export function AuthRedirect({ }, [shouldRedirect, router, to]); if (initializing || shouldRedirect) { - return ( -
- -
- ); + return ; } return <>{children}; diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 6bc2d72..3cf7657 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { Table } from 'react-bootstrap'; -import { ProgressCircle, Select, Button } from '@bcgov/design-system-react-components'; +import { Select, Button } from '@bcgov/design-system-react-components'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; +import { CenteredProgress } from '@/app/ui/base/CenteredProgress'; export interface Column { key: string; @@ -21,6 +22,7 @@ export interface DataTableProps { emptyMessage?: string; loadingMessage?: string; itemName?: string; + caption?: string; pageSize?: number; currentPage?: number; totalItems?: number; @@ -38,6 +40,7 @@ export function DataTable({ emptyMessage = 'No items found.', loadingMessage = 'Loading...', itemName = 'items', + caption, pageSize = 10, currentPage = 1, totalItems, @@ -51,11 +54,13 @@ export function DataTable({ return (
- + {caption ? : null} + {columns.map((col) => ( {loading ? ( - ) : data.length === 0 ? ( @@ -82,7 +84,7 @@ export function DataTable({ ) : ( data.map((item) => ( - + {columns.map((col) => (
{caption}
@@ -67,11 +72,8 @@ export function DataTable({
-
- - {loadingMessage} -
+
+
({ {!loading && data.length > 0 && totalItems !== undefined && (
Items per page: diff --git a/frontend/src/components/Modal.module.css b/frontend/src/components/Modal.module.css new file mode 100644 index 0000000..ba7efe8 --- /dev/null +++ b/frontend/src/components/Modal.module.css @@ -0,0 +1,35 @@ +/* + * Baseline layout for app "form" dialogs (import/export, preview, etc.). + * + * The BC Design System plain `Dialog` only stacks its children with no internal + * structure, which is why these looked basic. We give them the same region + * model the DS `AlertDialog` uses — header / body / footer separated by thin + * borders — reusing the same design tokens so the spacing matches the rest of + * the system (e.g. the disclaimer dialog). + */ + +.header { + padding: var(--layout-padding-medium) var(--layout-padding-large); + border-bottom: var(--layout-border-width-small) solid var(--surface-color-border-default); +} + +.title { + margin: 0; + font: var(--typography-bold-h5); + color: var(--typography-color-primary); +} + +/* + * Body sits between the two thin borders. It's a flex column so passed-in + * content stretches to the full width of the padded area by default. + */ +.body { + display: flex; + flex-direction: column; + padding: var(--layout-padding-medium) var(--layout-padding-large); + border-bottom: var(--layout-border-width-small) solid var(--surface-color-border-default); +} + +.footer { + padding: var(--layout-padding-medium) var(--layout-padding-large); +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 7235726..ecdf469 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Modal as BCModal, Dialog, Heading, ButtonGroup } from '@bcgov/design-system-react-components'; +import styles from './Modal.module.css'; export interface ModalProps { show: boolean; @@ -40,9 +41,17 @@ export function Modal({ show, title, onClose, children, size = 'lg', footer }: M style={{ width: WIDTH_BY_SIZE[size], maxWidth: '100vw' }} > - {title} - {children} - {footer && {footer}} +
+ + {title} + +
+
{children}
+ {footer && ( +
+ {footer} +
+ )}
); diff --git a/frontend/src/features/designer/ui/FormDesigner.tsx b/frontend/src/features/designer/ui/FormDesigner.tsx index be72c39..292348b 100644 --- a/frontend/src/features/designer/ui/FormDesigner.tsx +++ b/frontend/src/features/designer/ui/FormDesigner.tsx @@ -15,6 +15,7 @@ import type { } from '@formio/react'; import './FormDesigner.module.css'; import { Modal as CommonModal } from '@/src/components/Modal'; +import { CenteredProgress } from '@/app/ui/base/CenteredProgress'; import { TextArea, Button, InlineAlert } from '@bcgov/design-system-react-components'; import { useNotificationStore } from '@/lib/hooks/useNotificationStore'; @@ -232,7 +233,7 @@ const FormDesigner: React.FC = ({ onUpdateModel, initialModel = n }, [importJson, onUpdateModel, sanitizeForm, addNotification, dict.form.invalidJson]); if (initializing) { - return
Loading Designer...
; + return ; } if (!authenticated) { diff --git a/frontend/src/features/designer/ui/FormDesignerLoader.tsx b/frontend/src/features/designer/ui/FormDesignerLoader.tsx index 184e2c5..108dd9d 100644 --- a/frontend/src/features/designer/ui/FormDesignerLoader.tsx +++ b/frontend/src/features/designer/ui/FormDesignerLoader.tsx @@ -2,10 +2,11 @@ import dynamic from 'next/dynamic'; import React from 'react'; +import { CenteredProgress } from '@/app/ui/base/CenteredProgress'; const FormForm = dynamic(() => import('./FormForm'), { ssr: false, - loading: () =>
Loading Form Designer...
, + loading: () => , }); export default function FormDesignerLoader({ id }: { id?: string[] }) { diff --git a/frontend/src/features/designer/ui/FormForm.tsx b/frontend/src/features/designer/ui/FormForm.tsx index 4d06652..7526b06 100644 --- a/frontend/src/features/designer/ui/FormForm.tsx +++ b/frontend/src/features/designer/ui/FormForm.tsx @@ -3,19 +3,15 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { Tabs, Tab } from 'react-bootstrap'; import { - ProgressCircle, InlineAlert, Button, Form, TextField, - TextArea, Select, CheckboxGroup, Checkbox, - Modal as BCModal, - AlertDialog, } from '@bcgov/design-system-react-components'; -import { FaInfoCircle } from 'react-icons/fa'; +import { CenteredProgress } from '@/app/ui/base/CenteredProgress'; import { Modal as CommonModal } from '@/src/components/Modal'; import styles from './FormForm.module.css'; @@ -86,7 +82,6 @@ function FormForm({ id }: { id?: string[] }) { const [isDirty, setIsDirty] = useState(false); const [showPreview, setShowPreview] = useState(false); - const [showDisclaimerModal, setShowDisclaimerModal] = useState(false); const [agreedDisclaimer, setAgreedDisclaimer] = useState(false); useEffect(() => { @@ -151,11 +146,6 @@ function FormForm({ id }: { id?: string[] }) { [slugManuallyEdited], ); - const updateDescription = (value: string) => { - setFormDesc(value); - setIsDirty(true); - }; - const updateFormSchema = useCallback((data: FormType) => { setFormSchema(data); setIsDirty(true); @@ -299,12 +289,12 @@ function FormForm({ id }: { id?: string[] }) { } }; - if (!authenticated) { - return
You must be logged in
; + if (initializing) { + return ; } - if (initializing) { - return
Forms initializing
; + if (!authenticated) { + return
{dict.general.notAuthenticated}
; } const renderDesignerContent = () => ( @@ -353,30 +343,21 @@ function FormForm({ id }: { id?: string[] }) { ))} -
- - {dict.form.disclaimerLabel || - 'I agree to the disclaimer and statement of responsibility'} - - setShowDisclaimerModal(true)} - /> -
+ + {dict.form.disclaimerLabel || + 'I agree to the disclaimer and statement of responsibility'} + {/* Form Builder */}
{id && id[0] ? ( loading ? ( -
- -
+ ) : formSchema ? ( ) : ( @@ -387,15 +368,8 @@ function FormForm({ id }: { id?: string[] }) { )}
- {/* Description */} -
-