UNPKG

wcz-layout

Version:

582 lines (494 loc) 28.3 kB
# Migrate wcz-layout v9 to v10 Two headline changes: 1. Login moves to a `/login` page where the user picks a provider. AWS Cognito joins Entra. Entra-only apps behave as before, apart from the new page. 2. Every paid MUI X package is gone. `LayoutTable` (TanStack Table v9) replaces `DataGridPremium`, pickers move to the free `@mui/x-date-pickers`, and there is no licence key any more. Work through the steps in order. Commit the migration edits before running `vp check --fix`. On a repo not already formatted to the current config it rewrites hundreds of unrelated files, and the middleware arrays you need to eyeball vanish into the churn. ## 1. Dependencies | Action | Package | Version | | ------- | ---------------------------------- | ----------- | | Install | `@tanstack/react-table` | 9.x | | Install | `@tanstack/react-virtual` | 3.x | | Install | `@tanstack/react-hotkeys` | 0.x | | Install | `@mui/x-date-pickers` | 9.x | | Upgrade | `@tanstack/react-query` | **≥ 5.102** | | Upgrade | `@tanstack/react-router-ssr-query` | latest 1.x | | Delete | `@mui/x-data-grid-premium` | — | | Delete | `@mui/x-date-pickers-pro` | — | ```bash npm install @tanstack/react-table@9 @tanstack/react-virtual@3 @tanstack/react-hotkeys@0 @mui/x-date-pickers@9 npm install @tanstack/react-query@latest @tanstack/react-router-ssr-query@latest npm uninstall @mui/x-data-grid-premium @mui/x-date-pickers-pro ``` **Both query upgrades are mandatory** and neither failure names a version: - react-query below 5.102 lacks `QueryKeyWithDataTag`, which v10's `.d.mts` reference. Every `wcz-layout/data/client` export then resolves to `unknown`, surfacing as `TS18046: 'x' is of type 'unknown'` scattered through your own components. - ssr-query below 1.167.2 hydrates against the pre-5.102 shape and throws `Cannot read properties of undefined (reading 'mutations')` in the browser. No type error, so `vp check` passes and the page silently falls back to client rendering. If `@mui/material` is pinned to an exact version — common, to match the pinned MUI X Pro packages — loosen it to a caret range, or the install fails `ERESOLVE` against a newer `@mui/icons-material`. Then: rewrite every `@mui/x-date-pickers-pro` reference to `@mui/x-date-pickers`, including the `/// <reference types="@mui/x-date-pickers-pro/themeAugmentation" />` line atop `src/hooks/useTheme.ts` — not an import, so grepping for one misses it. Delete every `@mui/x-license` import from `src/router.tsx` and what it feeds — `LicenseInfo.setLicenseKey`, plus `muiXTelemetrySettings.disableTelemetry()` where it is used. Drop `VITE_MUI_LICENSE_KEY` from `src/env.ts` and `.env*`, and remove it from Vault. If that empties the `client` block, delete `clientEnv` and its import in `src/router.tsx`; delete any `.env*` file left empty. The range pickers were Pro-only, so `DateRangePicker`, `TimeRangePicker` and `DateTimeRangePicker` are gone from `useLayoutForm`. Replace each with two adjacent single-value fields, validate the ordering in the form schema, and delete the `MuiDateRangePicker` / `MuiDateTimeRangePicker` entries from `useTheme.ts`. ## 2. Renamed auth exports | v9 | v10 | From | | ---------------------------------------------- | ----------------------- | ----------------------- | | `requirePermission("key")` | `requireAuth("key")` | `wcz-layout/utils` | | `requirePermission("all")` | `requireAuth()` | `wcz-layout/utils` | | `authorizationMiddleware("key")` | `authMiddleware("key")` | `wcz-layout/middleware` | | `authorizationMiddleware("all")` | `authMiddleware()` | `wcz-layout/middleware` | | `authenticationMiddleware()` | `authMiddleware()` | `wcz-layout/middleware` | | `authenticationMiddleware({ optional: true })` | dropped, see step 6 | | **The "any employee" key must go.** v9 apps carried a key listing the company-wide employee groups (`wcz-all-employees`, `wscz-all-employees`) to mean "anyone signed in". v10 says that with no key at all. Delete the entry from `permissions.ts` — leaving it breaks AWS, whose sessions carry no groups, so a key-checked guard rejects every Cognito user. **Find it by its groups, not its name.** It is usually called `all`, but not always — one app named it `tester`. Read `permissions.ts` and pick the key whose value is the company-wide employee groups; that is the one to translate and delete, whatever it is called. Keys naming a real group (`admin` and friends) stay. Replace any `hasPermission(user, <that key>)` with `!!user`. New: `LoginForm` (`wcz-layout/components`), `loginProvidersQueryOptions` (`wcz-layout`), `Permissions` and `Scopes` types (`wcz-layout/models`), and `userMiddleware` (`wcz-layout/middleware`) — puts the caller in `context.user`, `null` when anonymous, requires nobody. Unchanged: `hasPermission`, `getUser`, `getAccessToken`, `getAppToken`, `handleLogin`, `handleCallback`, `handleLogout`, `csrfMiddleware`, `validationMiddleware`. `User` gains an additive `provider` field. Removed from `wcz-layout/components`: `RouterGridActionsCellItem` and `EditableColumnHeader` (grid wrappers — row actions now live in a `display` column), and `TypographyWithIcon` (inline a `Stack direction="row"` with the icon and a `Typography`). ## 3. src/lib/auth/permissions.ts ```ts import type { Permissions } from "wcz-layout/models"; export const permissions = { admin: ["wcz-developers"], } as const satisfies Permissions; ``` ## 4. src/lib/auth/scopes.ts Arrays become strings, one scope per key. The object form is only for an API an AWS session must reach. ```ts import type { Scopes } from "wcz-layout/models"; export const scopes = { graph: "User.Read", api: "api://wistron.com/project-name/access_as_user", // reachable by both providers: // file: { entra: "api://…/access_as_user", aws: "https://…/access_as_user" }, } as const satisfies Scopes; ``` Keep `as const satisfies` on both files — `as const` is what keeps `requireAuth("admin")` and `getAccessToken("file")` type-checked. ## 5. src/routes/login.tsx, new and required `requireAuth` redirects unauthenticated users to `/login`; without this file they 404. ```tsx import { createFileRoute } from "@tanstack/react-router"; import { loginProvidersQueryOptions } from "wcz-layout"; import { LoginForm } from "wcz-layout/components"; export const Route = createFileRoute("/login")({ loader: ({ context }) => context.queryClient.query(loginProvidersQueryOptions), component: LoginForm, }); ``` The loader makes the buttons server-render. `LoginForm` takes no props; it reads `?returnTo` itself and shows one button per configured provider. `tsc` will report `'"/login"' is not assignable to 'keyof FileRoutesByPath'` until the route tree regenerates — run `vp check` before chasing it. ## 6. src/server/middleware/databaseMiddleware.ts Only if the app has one. The change is one line — swap its auth dependency: ```diff - .middleware([authenticationMiddleware({ optional: true })]) // or authenticationMiddleware() + .middleware([userMiddleware]) ``` Keep the shape the app already has. If it is a plain exported middleware object, it stays one. If it is a factory that existed only to carry an `optional` flag, the generic factory, the pre-built instances and the overloads all go — there is one signature now. ### Did it authenticate on its own? Check what the v9 middleware composed before touching any call site: - `authenticationMiddleware({ optional: true })` — required nobody. Nothing opens up. - `authenticationMiddleware()`**required a signed-in user**, and every call site that stood alone inherited that. `userMiddleware` requires nobody, so each of those now serves anonymous callers unless you add `authMiddleware()` yourself. | v9 call site | v9 meaning | v10 | | ---------------------------------------------------------- | ------------------- | -------------------------------------------------- | | `databaseMiddleware({ optional: true })` | anonymous allowed | `[databaseMiddleware()]` | | `databaseMiddleware()` over `authenticationMiddleware()` | user required | `[databaseMiddleware(), authMiddleware()]` | | `[authorizationMiddleware("admin"), databaseMiddleware()]` | permission required | `[databaseMiddleware(), authMiddleware("admin")]` | Grep the middleware name, not `createServerFn` — route `server.middleware` arrays count too, and job, cron and webhook endpoints are where a lone `databaseMiddleware` hides. Those are the worst ones to leave anonymous. ### Order: `databaseMiddleware` first Middleware context merges left to right and the later entry wins, so the order decides whether `context.user` is nullable in the handler. `databaseMiddleware` declares `userMiddleware` and contributes `user: User | null`; `authMiddleware` contributes `user: User`. Put the database one first and the narrowed type survives: ```ts .middleware([databaseMiddleware(), authMiddleware("admin")]) .handler(({ context }) => context.user.name) // User, no narrowing needed ``` v9 arrays are usually written the other way round, so reorder them as you go. Any `validationMiddleware` in a REST route still goes **after** `authMiddleware`, or an unauthenticated caller gets their body parsed and your validation errors back: `[databaseMiddleware(), authMiddleware("admin"), validationMiddleware(Schema)]`. ## 7. src/start.ts v10 apps need no `src/start.ts` just for CSRF. When the file is absent, TanStack Start installs its own default request middleware, `createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" })` — exactly what `csrfMiddleware` from `wcz-layout/middleware` is. Open the file and read what `createStart` returns: - **Only `requestMiddleware: [csrfMiddleware]`**, with `csrfMiddleware` imported from `wcz-layout/middleware` and no other option → delete `src/start.ts` completely. - **Anything else** — a second entry in `requestMiddleware`, a `functionMiddleware` array, any other option, or a `createCsrfMiddleware` of the app's own with a different filter → keep the file as it is. After deleting, `src/routeTree.gen.ts` still imports `startInstance` from `./start.ts` until the route tree regenerates — run `vp check` before chasing it. Nothing else should import `startInstance`; grep to confirm. ## 8. Locales The app owns every string — the vite plugin builds i18next resources from the app's `src/lib/locales/*.json`, so a key the library renders but the app does not define comes out raw. The package does not ship its locale files: the complete v10 `Layout` block for `en` and `cs` is at the end of this guide, under [Appendix: v10 Layout locales](#appendix-v10-layout-locales). v10 adds the whole `Layout.Table` namespace plus `Layout.ContinueWith` and `Layout.TestEnvironment`. Take only the `Layout` key; nothing else in the app's locale files is the library's. **Merge it into the app's `Layout`, do not replace the block.** Apps add their own keys inside that namespace (`Layout.Loading`, `Layout.NotProvided` in one app), and a wholesale replace silently deletes them — which *does* break the build, because `src/types/i18next.d.ts` types `t()` from `en.json` and the app's own `t("Layout.Loading")` calls stop compiling. So: overlay the library's keys on top of the app's, then look at what is left over. A leftover key the app's own code references is the app's — keep it. One nothing references is a v9 library key v10 dropped — remove it, or it lingers forever because it passes every check. **Use the block from the guide of the major you are migrating to.** Do this step again in every major's pass: `Layout` keys change between majors, and each guide's appendix holds its own major's set. A v9 → v11 upgrade runs the v10 pass with this appendix and the v11 pass with the v11 guide's. Carrying v10's block into a v11 app leaves the wrong key set. **A library key the app never calls can go missing without failing the build.** `t()` is typed from the app's `en.json`, so it catches the app's own calls and nothing else — the library's are compiled already. A `Layout.Table.*` key the app never references therefore survives `vp check` and surfaces only as a raw key string on screen. Verify deliberately: compare the app's `Layout` key set against the appendix, and look at the login card and the table toolbar, where a miss shows first. **Every language the app ships needs the new keys, not just the two.** The appendix covers `en` and `cs`; an app may have any number of files in `src/lib/locales/`. For every other file (`de.json`, `pl.json`, `zh.json`, …), take the **English** block and translate it into that file's language yourself, as part of this step — do not ask, do not leave English values in place, do not skip the file. Nobody else will, and the gap is invisible until someone switches language. - Merge the same way as above: keys the app already translated keep their existing values; translate only the keys the file is missing. - Translate values only. Keys, `{{provider}}` / `{{name}}` / `{{count}}` interpolations and the `<env>` tag stay byte-for-byte. - Emit the plural suffixes the target language needs, not English's. English has `_one` / `_other`, Czech adds `_few`, Polish needs `_one` / `_few` / `_many` / `_other`, and Chinese or Japanese only `_other`. When unsure, check `new Intl.PluralRules("<lang>").resolvedOptions().pluralCategories`; a category that only applies to fractions (Czech `many`) can be left out, `count` is whole. - Match the tone of the file's existing strings (formal vs. informal address). `Layout.TestEnvironment` holds an `<env>` tag the mascot renders through `Trans` — keep the tag in every language, it colours the word. ## 9. Replace the grid **Only where `DataGridPremium` is used.** A table already built on TanStack Table stays exactly as written — do not convert it to `LayoutTable`. This step exists because the paid grid is gone, not because `LayoutTable` is the house table for everything. Grep for `DataGridPremium`; anything else is out of scope. Read the `table` skill before writing any columns — it documents the API. This is only the mapping. Delete `src/components/DataGridToolbar.tsx`; `LayoutTable` renders the toolbar, quick filter, columns panel and filter panel itself. In `useTheme.ts`, drop `import { csCZ, enUS } from "@mui/x-data-grid/locales"` and its two entries. | DataGridPremium | LayoutTable | | --------------------------------------------------- | ------------------------------------------------------------- | | `Array<GridColDef<Row>>` | `columnHelper.columns([...])` from `createLayoutColumnHelper` | | `field` / `headerName` | the accessor key / `header` | | `width`, `flex`, `minWidth` | `size` | | `renderCell` | `cell: ({ cell }) => <cell.ValueCell />` | | `valueGetter: (_v, row) => row.x.y` | `columnHelper.accessor((row) => row.x.y, { id: "..." })` | | `align` / `headerAlign` | `meta: { align }` | | `initialState.pinnedColumns.left` / `.right` | `initialState.columnPinning.start` / `.end` | | `initialState.sorting.sortModel` `{field, sort}` | `initialState.sorting` `[{ id, desc }]` | | `valueFormatter` on a date column | `cell.DateCell` or `cell.DateTimeCell` | | `valueFormatter` with an `Intl.NumberFormat` | `cell.NumberCell options={...}`, same options object | | `type: "singleSelect"` with `valueOptions` | `meta: { variant: "select", options }` | | `type: "number"` / `"date"` / `"boolean"` | `meta: { variant: "number" \| "date" \| "boolean" }` | | `rows` and `columns` props | `useLayoutTable({ data, columns })`, then `table={table}` | | `showToolbar` with `slots.toolbar` | `showToolbar` with `title` and `actions` on `LayoutTable` | | `ignoreDiacritics` | always on | | `rowSelectionModel` state | `table.getSelectedRowIds()`, `table.resetRowSelection()` | | `cellSelection` | always on, `Mod+C` copies the range as TSV | | `GridActionsCellItem` in a `type: "actions"` column | a `display` column rendering `cell.ActionsCell` | **Every row needs an `id`.** `getRowId` is not an option — the library hardcodes `row.id`. A table over a view or a natural-key table without an `id` column must add one where the rows are built (`id: row.cpn`), and the row type gains `id: string`. **Dropped with no replacement**, so decide per case whether to rebuild or drop: `getRowClassName` and `cellClassName` (style inside the cell instead — a `sx` on `cell.NumberCell` colours the text, nothing reaches the row), `rowHeight`, the Excel export button, and `localeText` overrides such as `footerTotalRows`. Any `sx` block targeting `.MuiDataGrid-*` classes is dead: delete it rather than translating it, LayoutTable's DOM is unrelated. **State persistence changes shape.** `apiRef.exportState()` / `restoreState()` and the per-change callbacks (`onSortModelChange`, `onPinnedColumnsChange`, …) are gone. Restore by passing `initialState` read from storage before first render, and persist with one effect on `table.state`. There is no `onStateChange` option. **Shared generic column factories do not port.** A v9 helper like `actionColumn<T>(): GridColDef<T>` reused across pages cannot be rewritten as `(helper: ColumnHelper<T>) => ...``createLayoutColumnHelper` is invariant in its row type, so a concrete helper will not satisfy a generic parameter. Share the *contents* instead: a function returning `Array<RowAction<T>>` (that type is generic-friendly) or a plain predicate, and let each page write its own five-line `display` column. **Inline editing has no equivalent.** No `editMode`, no `processRowUpdate`, no editable columns, and `EditableColumnHeader` is gone. A page built on `editMode="row"` is a redesign, not a column mapping: move create and edit into a dialog with a `useLayoutForm` and leave the table read-only. Usually the largest item in the whole migration. It also retires `rowModesModel`, the `isNew` row flag and any "turn the Zod error into a string for the grid" helper — delete them, the form renders field errors itself. **Bulk actions need a selection column.** A v9 page driving actions off `Object.keys(cellSelectionModel)` has nothing to map onto: cell selection is always on in v10 but exists for `Mod+C`. `table.getSelectedRowIds()` reads *row* selection, which renders only if the columns start with a `select` display column (`header.SelectAllCell` / `cell.SelectCell`, `size: 50`, `enableResizing: false`, `enableCellSelection: false`). Ids come back as plain strings, so the `id.toString()` that `GridRowId` forced can go. Two behaviours that catch people out. `meta.variant: "select"` and `"multi-select"` read options from the column's faceted unique values, so pass `meta.options` only when the label differs from the stored value — a translated database enum, derived from `enumValues`. And grouping shows a value only for the grouping column and columns that resolve an aggregation function (a number or `Date`, unless the column sets `aggregationFn`); others stay blank in a group row. ## 10. useNotification `useNotification` is gone, along with its snackbar and the `snackbarOrigin` layout option. Replace every call with something the page renders itself — a success animation, a non-dismissing alert dialog for errors. Match whatever the app already does for feedback rather than introducing a new pattern. Two things to check: - A `notify(..., { severity: "error" })` sitting next to an `alert(error.message)` from `useDialogs` is redundant. Delete the `notify`, keep the `alert`. - A success `notify` that fires **after** a `navigate` cannot become a component on the page it was called from — that page has already unmounted. Invert it: render the feedback, and navigate when it finishes. ## 11. AWS Cognito, optional Skip for Entra-only apps. To offer AWS, add to the `server` block of `src/env.ts`: ```ts AWS_ISSUER: z.url().optional(), // https://cognito-idp.<region>.amazonaws.com/<poolId> AWS_DOMAIN: z.url().optional(), // https://<prefix>.auth.<region>.amazoncognito.com AWS_CLIENT_ID: z.string().min(1).optional(), AWS_CLIENT_SECRET: z.string().min(1).optional(), ``` Set all four in `.env.local`; the second button appears only when all are present. The Cognito app client must be confidential, use the authorization-code grant, and list `<origin>/auth/callback` as callback and `<origin>/` as sign-out URL. An AWS session requests OIDC scopes only, so it cannot mint a delegated API token. Cognito cannot widen scopes after login, so adding a downstream API means requesting every `aws` scope in `scopes.ts` at login, and an unregistered one fails the whole sign-in — register them all on the app client first. Downstream APIs must also accept the Cognito JWKS and authorize on the token's `scope` claim, since Cognito access tokens carry no `aud`. ## Appendix: v10 Layout locales The complete `Layout` block wcz-layout v10 renders, for step 8. Merge it into the app's `src/lib/locales/en.json` and `cs.json` as that step describes — do not paste it over the app's block. For any other language, translate the English block. ### en.json ```json { "Layout": { "LogIn": "Log In", "ContinueWith": "Continue with {{provider}}", "Logout": "Logout", "Language": "Language", "Appearance": "Appearance", "Light": "Light", "Dark": "Dark", "System": "System", "ThisPageCouldNotBeFound": "This page could not be found.", "Settings": "Settings", "Unauthorized": "Unauthorized", "TestEnvironment": "You’re in the <env>TEST</env> environment", "Dialog": { "Confirm": "Confirm", "Cancel": "Cancel", "Alert": "Alert" }, "File": { "AreYouSureYouWantToDelete": "Are you sure you want to delete {{name}}?", "DragSomeFilesHereOrClickToSelectThem": "Drag some files here or click to select them", "Delete": "Delete", "Download": "Download" }, "Table": { "Search": "Search", "ClearSearch": "Clear search", "ColumnMenu": "Column menu", "Filters": "Filters", "AddFilter": "Add filter", "RemoveFilter": "Remove filter", "ClearFilters": "Clear filters", "And": "and", "Or": "or", "Field": "Field", "Operator": "Operator", "Value": "Value", "NoValueRequired": "No value required", "From": "From", "To": "To", "DaysFromToday": "Days from today", "NoRows": "No rows", "SortAscending": "Sort ascending", "SortDescending": "Sort descending", "ClearSort": "Clear sort", "PinStart": "Pin to start", "PinEnd": "Pin to end", "Unpin": "Unpin", "GroupBy": "Group by this column", "Ungroup": "Stop grouping", "RowActions": "Row actions", "SelectedCount_one": "{{count}} selected", "SelectedCount_other": "{{count}} selected", "MoveLeft": "Move left", "MoveRight": "Move right", "All": "All", "Columns": "Columns", "ShowHideAll": "Show/Hide all", "HideColumn": "Hide column", "Operators": { "includesString": "contains", "notIncludesString": "does not contain", "equalsString": "is", "notEqualsString": "is not", "startsWith": "starts with", "endsWith": "ends with", "isEmpty": "is empty", "isNotEmpty": "is not empty", "equals": "is", "notEquals": "is not", "greaterThan": "is greater than", "greaterThanOrEqualTo": "is greater than or equal to", "lessThan": "is less than", "lessThanOrEqualTo": "is less than or equal to", "inRange": "is between", "isRelativeToToday": "is relative to today", "Date": { "lessThan": "is before", "lessThanOrEqualTo": "is on or before", "greaterThan": "is after", "greaterThanOrEqualTo": "is on or after" } } } } } ``` ### cs.json ```json { "Layout": { "LogIn": "Přihlásit se", "ContinueWith": "Pokračovat přes {{provider}}", "Logout": "Odhlásit se", "Language": "Jazyk", "Appearance": "Vzhled", "Light": "Světlý", "Dark": "Tmavý", "System": "Systém", "ThisPageCouldNotBeFound": "Tato stránka nebyla nalezena.", "Settings": "Nastavení", "Unauthorized": "Neoprávněný", "TestEnvironment": "Jste v <env>TESTOVACÍM</env> prostředí", "Dialog": { "Confirm": "Potvrdit", "Cancel": "Zrušit", "Alert": "Upozornění" }, "File": { "AreYouSureYouWantToDelete": "Opravdu chcete smazat {{name}}?", "DragSomeFilesHereOrClickToSelectThem": "Přetáhněte sem některé soubory nebo je vyberte kliknutím", "Delete": "Smazat", "Download": "Stáhnout" }, "Table": { "Search": "Hledat", "ClearSearch": "Zrušit hledání", "ColumnMenu": "Nabídka sloupce", "Filters": "Filtry", "AddFilter": "Přidat filtr", "RemoveFilter": "Odebrat filtr", "ClearFilters": "Zrušit filtry", "And": "a", "Or": "nebo", "Field": "Pole", "Operator": "Operátor", "Value": "Hodnota", "NoValueRequired": "Hodnota není potřeba", "From": "Od", "To": "Do", "DaysFromToday": "Dnů od dneška", "NoRows": "Žádné řádky", "SortAscending": "Seřadit vzestupně", "SortDescending": "Seřadit sestupně", "ClearSort": "Zrušit řazení", "PinStart": "Připnout na začátek", "PinEnd": "Připnout na konec", "Unpin": "Odepnout", "GroupBy": "Seskupit podle sloupce", "Ungroup": "Zrušit seskupení", "RowActions": "Akce řádku", "SelectedCount_one": "{{count}} vybraný", "SelectedCount_few": "{{count}} vybrané", "SelectedCount_other": "{{count}} vybraných", "MoveLeft": "Posunout doleva", "MoveRight": "Posunout doprava", "All": "Vše", "Columns": "Sloupce", "ShowHideAll": "Zobrazit/skrýt vše", "HideColumn": "Skrýt sloupec", "Operators": { "includesString": "obsahuje", "notIncludesString": "neobsahuje", "equalsString": "je", "notEqualsString": "není", "startsWith": "začíná na", "endsWith": "končí na", "isEmpty": "je prázdné", "isNotEmpty": "není prázdné", "equals": "je", "notEquals": "není", "greaterThan": "je větší než", "greaterThanOrEqualTo": "je větší nebo rovno", "lessThan": "je menší než", "lessThanOrEqualTo": "je menší nebo rovno", "inRange": "je mezi", "isRelativeToToday": "je relativní ke dnešku", "Date": { "lessThan": "je před", "lessThanOrEqualTo": "je nejpozději", "greaterThan": "je po", "greaterThanOrEqualTo": "je nejdříve" } } } } } ```