@byline/host-tanstack-start 6.0.2 → 6.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
4
  import { Table } from "@byline/ui/react";
5
5
  import clsx from "clsx";
6
+ import { searchBooleanSchema } from "../../routes/search-boolean.js";
6
7
  import { useNavigate } from "./loose-router.js";
7
8
  import { SortAscendingIcon, SortDescendingIcon, SortNeutralIcon } from "./sort-icons.js";
8
9
  import th_sortable_module from "./th-sortable.module.js";
@@ -30,7 +31,7 @@ function TableHeadingCellSortable({ path: _path = '/', fieldName, label, sortabl
30
31
  if (null != fieldName) {
31
32
  const order = void 0 !== activeOrder ? activeOrder : location.search.order;
32
33
  const d = void 0 !== activeOrder ? activeDesc : location.search.desc;
33
- order === fieldName ? setDesc(d ?? false) : setDesc(null);
34
+ order === fieldName ? setDesc(searchBooleanSchema.safeParse(d).data ?? true) : setDesc(null);
34
35
  }
35
36
  }, [
36
37
  fieldName,
@@ -0,0 +1 @@
1
+ export {};
@@ -11,6 +11,7 @@ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalList
11
11
  import clsx from "clsx";
12
12
  import { getAdminRoutePath } from "../../routes/admin-path.js";
13
13
  import { encodeListReturnState } from "../../routes/list-return-state.js";
14
+ import { searchBooleanSchema } from "../../routes/search-boolean.js";
14
15
  import { setListViewPreference } from "../../server-fns/collections/index.js";
15
16
  import { Link, useNavigate } from "../chrome/loose-router.js";
16
17
  import { RouterPager } from "../chrome/router-pager.js";
@@ -94,10 +95,11 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
94
95
  mutation.blocked
95
96
  ]);
96
97
  const searchParams = location.search;
97
- const isCanonicalView = !searchParams.order && !searchParams.desc && !searchParams.query && !searchParams.status;
98
+ const searchDesc = searchBooleanSchema.safeParse(searchParams.desc).data;
99
+ const isCanonicalView = !searchParams.order && !searchDesc && !searchParams.query && !searchParams.status;
98
100
  const dragEnabled = orderable && isCanonicalView && !!onReorder && !mutation.blocked;
99
101
  const activeOrder = searchParams.order ?? data?.meta.order;
100
- const activeDesc = null != searchParams.order ? searchParams.desc : data?.meta.desc;
102
+ const activeDesc = null != searchParams.order ? searchDesc : data?.meta.desc;
101
103
  const sensors = useSensors(useSensor(PointerSensor, {
102
104
  activationConstraint: {
103
105
  distance: 5
@@ -6,6 +6,7 @@ import { AdminUsersListView } from "../admin-shell/admin-users/list.js";
6
6
  import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
7
7
  import { listAdminUsers } from "../server-fns/admin-users/index.js";
8
8
  import { getAdminRoutePath } from "./admin-path.js";
9
+ import { searchBooleanSchema } from "./search-boolean.js";
9
10
  const orderSchema = z["enum"]([
10
11
  'given_name',
11
12
  'family_name',
@@ -19,7 +20,7 @@ const searchSchema = z.object({
19
20
  page_size: z.coerce.number().int().min(1).max(100).optional(),
20
21
  query: z.string().optional(),
21
22
  order: orderSchema.optional(),
22
- desc: z.coerce.boolean().optional()
23
+ desc: searchBooleanSchema
23
24
  });
24
25
  function createAdminUsersListRoute(path) {
25
26
  const Route = createFileRoute(path)({
@@ -9,11 +9,12 @@ import { getCollectionDocument, getCollectionDocumentAuditLog, getCollectionDocu
9
9
  import { getAdminRoutePath } from "./admin-path.js";
10
10
  import { getContentLocaleRouteConfig } from "./get-content-locale-route-config.js";
11
11
  import { getMultiCollectionDefinition } from "./get-multi-collection-definition.js";
12
+ import { searchBooleanSchema } from "./search-boolean.js";
12
13
  const searchSchema = z.object({
13
14
  page: z.coerce.number().min(1).optional(),
14
15
  page_size: z.coerce.number().max(100).optional(),
15
16
  order: z.string().optional(),
16
- desc: z.coerce.boolean().optional(),
17
+ desc: searchBooleanSchema,
17
18
  locale: z.string().optional(),
18
19
  tab: z["enum"]([
19
20
  'versions',
@@ -9,11 +9,12 @@ import { getSingleton, getSingletonHistory } from "../server-fns/singletons/inde
9
9
  import { getAdminRoutePath } from "./admin-path.js";
10
10
  import { getContentLocaleRouteConfig } from "./get-content-locale-route-config.js";
11
11
  import { getSingletonDefinition } from "./get-singleton-definition.js";
12
+ import { searchBooleanSchema } from "./search-boolean.js";
12
13
  const searchSchema = z.object({
13
14
  page: z.coerce.number().min(1).optional(),
14
15
  page_size: z.coerce.number().max(100).optional(),
15
16
  order: z.string().optional(),
16
- desc: z.coerce.boolean().optional(),
17
+ desc: searchBooleanSchema,
17
18
  locale: z.string().optional()
18
19
  });
19
20
  function createSingletonHistoryRoute(path) {
@@ -24,7 +24,7 @@ export declare const collectionListSearchSchema: z.ZodObject<{
24
24
  page: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
25
25
  page_size: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
26
26
  order: z.ZodOptional<z.ZodString>;
27
- desc: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
27
+ desc: z.ZodPreprocess<z.ZodOptional<z.ZodBoolean>, unknown>;
28
28
  query: z.ZodOptional<z.ZodString>;
29
29
  locale: z.ZodOptional<z.ZodString>;
30
30
  status: z.ZodOptional<z.ZodString>;
@@ -38,8 +38,7 @@ export declare function encodeListReturnState(search: Record<string, unknown>):
38
38
  /**
39
39
  * Parse a `from` param back into list search params. Runs the decoded
40
40
  * pairs through the list search schema; anything malformed returns
41
- * `undefined` (→ bare list). `desc` needs explicit normalisation
42
- * because `z.coerce.boolean()` would coerce the string `'false'` to
43
- * `true`.
41
+ * `undefined` (→ bare list). The schema accepts both boolean and wire-string
42
+ * directions, including `'false'` for ascending.
44
43
  */
45
44
  export declare function decodeListReturnState(from: string | undefined): Record<string, unknown> | undefined;
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { searchBooleanSchema } from "./search-boolean.js";
2
3
  const collectionListSearchSchema = z.object({
3
4
  page: z.coerce.number().min(1).optional(),
4
5
  page_size: z.coerce.number().max(100).optional(),
5
6
  order: z.string().optional(),
6
- desc: z.coerce.boolean().optional(),
7
+ desc: searchBooleanSchema,
7
8
  query: z.string().optional(),
8
9
  locale: z.string().optional(),
9
10
  status: z.string().optional(),
@@ -33,7 +34,6 @@ function decodeListReturnState(from) {
33
34
  if (null == from || 0 === from.length) return;
34
35
  const raw = Object.fromEntries(new URLSearchParams(from));
35
36
  delete raw.action;
36
- if ('string' == typeof raw.desc) raw.desc = 'true' === raw.desc;
37
37
  const parsed = collectionListSearchSchema.safeParse(raw);
38
38
  if (!parsed.success) return;
39
39
  const result = {};
@@ -0,0 +1,2 @@
1
+ import { z } from 'zod';
2
+ export declare const searchBooleanSchema: z.ZodPreprocess<z.ZodOptional<z.ZodBoolean>, unknown>;
@@ -0,0 +1,3 @@
1
+ import { z } from "zod";
2
+ const searchBooleanSchema = z.preprocess((value)=>'true' === value ? true : 'false' === value ? false : value, z.boolean().optional());
3
+ export { searchBooleanSchema };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "6.0.2",
6
+ "version": "6.0.3",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -125,15 +125,15 @@
125
125
  "react-json-view-lite": "^2.5.0",
126
126
  "react-swipeable": "^7.0.2",
127
127
  "zod": "^4.6.2",
128
- "@byline/admin": "6.0.2",
129
- "@byline/auth": "6.0.2",
130
- "@byline/analytics": "6.0.2",
131
- "@byline/analytics-agent": "6.0.2",
132
- "@byline/client": "6.0.2",
133
- "@byline/ai": "6.0.2",
134
- "@byline/i18n": "6.0.2",
135
- "@byline/core": "6.0.2",
136
- "@byline/ui": "6.0.2"
128
+ "@byline/admin": "6.0.3",
129
+ "@byline/ai": "6.0.3",
130
+ "@byline/auth": "6.0.3",
131
+ "@byline/client": "6.0.3",
132
+ "@byline/analytics-agent": "6.0.3",
133
+ "@byline/i18n": "6.0.3",
134
+ "@byline/analytics": "6.0.3",
135
+ "@byline/core": "6.0.3",
136
+ "@byline/ui": "6.0.3"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@tanstack/react-router": "^1.167.0",
@@ -0,0 +1,142 @@
1
+ import { act, type ComponentProps } from 'react'
2
+ import {
3
+ createMemoryHistory,
4
+ createRootRoute,
5
+ createRoute,
6
+ createRouter,
7
+ RouterProvider,
8
+ useRouterState,
9
+ } from '@tanstack/react-router'
10
+
11
+ import { createRoot } from 'react-dom/client'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ import { collectionListSearchSchema } from '../../routes/list-return-state.js'
15
+ import { resolveListViewState } from '../../server-fns/collections/list-view-state.js'
16
+ import { TableHeadingCellSortable } from './th-sortable.js'
17
+
18
+ vi.mock('@byline/ui/react', () => ({
19
+ Table: {
20
+ HeadingCell: (props: ComponentProps<'th'>) => <th {...props} />,
21
+ },
22
+ }))
23
+
24
+ // FORRU preserves wire values as strings and repeated keys as arrays for its
25
+ // public library URLs. Admin routes share that router and must parse booleans.
26
+ function parseWireSearch(search: string): Record<string, unknown> {
27
+ const params = new URLSearchParams(search)
28
+ return Object.fromEntries(
29
+ [...new Set(params.keys())].map((key) => {
30
+ const values = params.getAll(key)
31
+ return [key, values.length === 1 ? values[0] : values]
32
+ })
33
+ )
34
+ }
35
+
36
+ function stringifyWireSearch(search: Record<string, unknown>): string {
37
+ const params = new URLSearchParams()
38
+ for (const [key, value] of Object.entries(search)) {
39
+ if (value == null) continue
40
+ for (const entry of Array.isArray(value) ? value : [value]) {
41
+ params.append(key, String(entry))
42
+ }
43
+ }
44
+ return params.size ? `?${params}` : ''
45
+ }
46
+
47
+ beforeEach(() => {
48
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
49
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
50
+ })
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks()
54
+ vi.unstubAllGlobals()
55
+ })
56
+
57
+ describe.each(['default', 'wire'] as const)('%s search parser', (parser) => {
58
+ it.each([true, false])(
59
+ 'toggles sort requests and indicators with effective metadata: %s',
60
+ async (effective) => {
61
+ const rootRoute = createRootRoute()
62
+ const route = createRoute({
63
+ getParentRoute: () => rootRoute,
64
+ path: '/admin/collections/news',
65
+ validateSearch: collectionListSearchSchema,
66
+ loaderDeps: ({ search }) => search,
67
+ loader: ({ deps }) =>
68
+ resolveListViewState({
69
+ params: deps,
70
+ preference: null,
71
+ orderable: false,
72
+ sortableFields: ['title'],
73
+ }),
74
+ component: () => {
75
+ const data = route.useLoaderData()
76
+ // Match ListView: location.search is the host parser's raw output,
77
+ // unlike route.useSearch(), which has already passed validation.
78
+ const location = useRouterState({ select: (s) => s.location })
79
+ const search = location.search as { order?: string; desc?: boolean }
80
+ return (
81
+ <table>
82
+ <thead>
83
+ <tr>
84
+ <TableHeadingCellSortable
85
+ fieldName="title"
86
+ label="Title"
87
+ sortable
88
+ {...(effective
89
+ ? {
90
+ activeOrder: search.order ?? data.metaOrder,
91
+ activeDesc: search.order != null ? search.desc : data.metaDesc,
92
+ }
93
+ : {})}
94
+ />
95
+ </tr>
96
+ </thead>
97
+ <tbody>
98
+ <tr>
99
+ <td>{JSON.stringify(data.sort)}</td>
100
+ </tr>
101
+ </tbody>
102
+ </table>
103
+ )
104
+ },
105
+ })
106
+ const router = createRouter({
107
+ routeTree: rootRoute.addChildren([route]),
108
+ history: createMemoryHistory({
109
+ initialEntries: ['/admin/collections/news?order=title&desc=true'],
110
+ }),
111
+ ...(parser === 'wire'
112
+ ? { parseSearch: parseWireSearch, stringifySearch: stringifyWireSearch }
113
+ : {}),
114
+ })
115
+ const container = document.createElement('div')
116
+ document.body.append(container)
117
+ const root = createRoot(container)
118
+ try {
119
+ await act(async () => {
120
+ await router.load()
121
+ root.render(<RouterProvider router={router} />)
122
+ })
123
+ expect(container.querySelector('button')?.textContent).toBe('TitleZA')
124
+ for (const direction of ['asc', 'desc', 'asc']) {
125
+ await act(async () => {
126
+ container.querySelector('button')?.click()
127
+ await vi.waitFor(() => expect(router.state.status).toBe('idle'))
128
+ })
129
+ expect(container.querySelector('td')?.textContent).toBe(
130
+ JSON.stringify({ title: direction })
131
+ )
132
+ expect(container.querySelector('button')?.textContent).toBe(
133
+ direction === 'asc' ? 'TitleAZ' : 'TitleZA'
134
+ )
135
+ }
136
+ } finally {
137
+ await act(async () => root.unmount())
138
+ container.remove()
139
+ }
140
+ }
141
+ )
142
+ })
@@ -13,6 +13,7 @@ import { useRouterState } from '@tanstack/react-router'
13
13
  import { Table } from '@byline/ui/react'
14
14
  import cx from 'clsx'
15
15
 
16
+ import { searchBooleanSchema } from '../../routes/search-boolean.js'
16
17
  import { useNavigate } from './loose-router.js'
17
18
  import { SortAscendingIcon, SortDescendingIcon, SortNeutralIcon } from './sort-icons.js'
18
19
  import styles from './th-sortable.module.css'
@@ -91,7 +92,9 @@ export function TableHeadingCellSortable({
91
92
  ? activeDesc
92
93
  : ((location.search as Record<string, unknown>).desc as boolean | undefined)
93
94
  if (order === fieldName) {
94
- setDesc(d ?? false)
95
+ // location.search can contain wire strings with a custom host parser.
96
+ // The route validator normalizes loader deps, not this raw location.
97
+ setDesc(searchBooleanSchema.safeParse(d).data ?? true)
95
98
  } else {
96
99
  setDesc(null)
97
100
  }
@@ -47,6 +47,7 @@ import cx from 'clsx'
47
47
 
48
48
  import { getAdminRoutePath } from '../../routes/admin-path.js'
49
49
  import { encodeListReturnState } from '../../routes/list-return-state.js'
50
+ import { searchBooleanSchema } from '../../routes/search-boolean.js'
50
51
  import { setListViewPreference } from '../../server-fns/collections/index.js'
51
52
  import { Link, useNavigate } from '../chrome/loose-router.js'
52
53
  import { RouterPager } from '../chrome/router-pager.js'
@@ -206,12 +207,13 @@ export const ListView = ({
206
207
  const searchParams = location.search as {
207
208
  page_size?: number
208
209
  order?: string
209
- desc?: boolean
210
+ desc?: boolean | string
210
211
  query?: string
211
212
  status?: string
212
213
  }
214
+ const searchDesc = searchBooleanSchema.safeParse(searchParams.desc).data
213
215
  const isCanonicalView =
214
- !searchParams.order && !searchParams.desc && !searchParams.query && !searchParams.status
216
+ !searchParams.order && !searchDesc && !searchParams.query && !searchParams.status
215
217
  const dragEnabled = orderable && isCanonicalView && !!onReorder && !mutation.blocked
216
218
 
217
219
  // The *effective* sort for the header indicators: explicit URL params win;
@@ -220,7 +222,7 @@ export const ListView = ({
220
222
  // which column ordered the rows. (Orderable collections never set
221
223
  // `meta.order` — their default is the drag order, indicated separately.)
222
224
  const activeOrder = searchParams.order ?? data?.meta.order
223
- const activeDesc = searchParams.order != null ? searchParams.desc : data?.meta.desc
225
+ const activeDesc = searchParams.order != null ? searchDesc : data?.meta.desc
224
226
 
225
227
  const sensors = useSensors(
226
228
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -15,6 +15,7 @@ import { AdminUsersListView } from '../admin-shell/admin-users/list.js'
15
15
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
16
16
  import { type AdminUserListResponse, listAdminUsers } from '../server-fns/admin-users/index.js'
17
17
  import { getAdminRoutePath } from './admin-path.js'
18
+ import { searchBooleanSchema } from './search-boolean.js'
18
19
 
19
20
  const orderSchema = z.enum([
20
21
  'given_name',
@@ -30,7 +31,7 @@ const searchSchema = z.object({
30
31
  page_size: z.coerce.number().int().min(1).max(100).optional(),
31
32
  query: z.string().optional(),
32
33
  order: orderSchema.optional(),
33
- desc: z.coerce.boolean().optional(),
34
+ desc: searchBooleanSchema,
34
35
  })
35
36
 
36
37
  export function createAdminUsersListRoute(path: string) {
@@ -23,12 +23,13 @@ import {
23
23
  import { getAdminRoutePath } from './admin-path.js'
24
24
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
25
25
  import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
26
+ import { searchBooleanSchema } from './search-boolean.js'
26
27
 
27
28
  const searchSchema = z.object({
28
29
  page: z.coerce.number().min(1).optional(),
29
30
  page_size: z.coerce.number().max(100).optional(),
30
31
  order: z.string().optional(),
31
- desc: z.coerce.boolean().optional(),
32
+ desc: searchBooleanSchema,
32
33
  locale: z.string().optional(),
33
34
  // Which sub-view of the history page is active (docs/07-auth-and-security/02-auditability.md — Workstream
34
35
  // 3). 'versions' is the content version stream; 'document' is the
@@ -18,13 +18,14 @@ import { getSingleton, getSingletonHistory } from '../server-fns/singletons/inde
18
18
  import { getAdminRoutePath } from './admin-path.js'
19
19
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
20
20
  import { getSingletonDefinition } from './get-singleton-definition.js'
21
+ import { searchBooleanSchema } from './search-boolean.js'
21
22
  import type { VersionHistoryData } from '../admin-shell/collections/version-history.js'
22
23
 
23
24
  const searchSchema = z.object({
24
25
  page: z.coerce.number().min(1).optional(),
25
26
  page_size: z.coerce.number().max(100).optional(),
26
27
  order: z.string().optional(),
27
- desc: z.coerce.boolean().optional(),
28
+ desc: searchBooleanSchema,
28
29
  locale: z.string().optional(),
29
30
  })
30
31
 
@@ -23,11 +23,13 @@
23
23
 
24
24
  import { z } from 'zod'
25
25
 
26
+ import { searchBooleanSchema } from './search-boolean.js'
27
+
26
28
  export const collectionListSearchSchema = z.object({
27
29
  page: z.coerce.number().min(1).optional(),
28
30
  page_size: z.coerce.number().max(100).optional(),
29
31
  order: z.string().optional(),
30
- desc: z.coerce.boolean().optional(),
32
+ desc: searchBooleanSchema,
31
33
  query: z.string().optional(),
32
34
  locale: z.string().optional(),
33
35
  status: z.string().optional(),
@@ -56,9 +58,8 @@ export function encodeListReturnState(search: Record<string, unknown>): string |
56
58
  /**
57
59
  * Parse a `from` param back into list search params. Runs the decoded
58
60
  * pairs through the list search schema; anything malformed returns
59
- * `undefined` (→ bare list). `desc` needs explicit normalisation
60
- * because `z.coerce.boolean()` would coerce the string `'false'` to
61
- * `true`.
61
+ * `undefined` (→ bare list). The schema accepts both boolean and wire-string
62
+ * directions, including `'false'` for ascending.
62
63
  */
63
64
  export function decodeListReturnState(
64
65
  from: string | undefined
@@ -66,7 +67,6 @@ export function decodeListReturnState(
66
67
  if (from == null || from.length === 0) return undefined
67
68
  const raw: Record<string, unknown> = Object.fromEntries(new URLSearchParams(from))
68
69
  delete raw.action
69
- if (typeof raw.desc === 'string') raw.desc = raw.desc === 'true'
70
70
  const parsed = collectionListSearchSchema.safeParse(raw)
71
71
  if (!parsed.success) return undefined
72
72
  const result: Record<string, unknown> = {}
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveListViewState } from '../server-fns/collections/list-view-state.js'
4
+ import { collectionListSearchSchema, decodeListReturnState } from './list-return-state.js'
5
+ import { searchBooleanSchema } from './search-boolean.js'
6
+
7
+ describe('search booleans', () => {
8
+ it.each([
9
+ [true, true],
10
+ [false, false],
11
+ ['true', true],
12
+ ['false', false],
13
+ [undefined, undefined],
14
+ ])('parses %s as %s', (input, expected) => {
15
+ expect(searchBooleanSchema.parse(input)).toBe(expected)
16
+ })
17
+
18
+ it.each(['invalid', '', ['false'], null, 0, 1])('rejects ambiguous input %s', (input) => {
19
+ expect(searchBooleanSchema.safeParse(input).success).toBe(false)
20
+ })
21
+
22
+ it.each(['true', 'false'])('carries desc=%s from wire params into the list query', (desc) => {
23
+ const params = collectionListSearchSchema.parse(
24
+ Object.fromEntries(new URLSearchParams(`order=title&desc=${desc}`))
25
+ )
26
+ const result = resolveListViewState({
27
+ params,
28
+ preference: null,
29
+ orderable: false,
30
+ sortableFields: ['title'],
31
+ })
32
+ expect(result.sort).toEqual({ title: desc === 'true' ? 'desc' : 'asc' })
33
+ expect(result.metaDesc).toBe(desc === 'true')
34
+ })
35
+
36
+ it('rejects a malformed return direction instead of silently changing it to ascending', () => {
37
+ expect(decodeListReturnState('order=title&desc=invalid')).toBeUndefined()
38
+ })
39
+ })
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ // Host routers may preserve URL values as strings. Boolean('false') is true,
4
+ // so boolean coercion cannot be used for an explicit URL direction.
5
+ export const searchBooleanSchema = z.preprocess(
6
+ (value) => (value === 'true' ? true : value === 'false' ? false : value),
7
+ z.boolean().optional()
8
+ )