@nixxie-cms/core 2.1.0 → 2.2.0

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.
Files changed (126) hide show
  1. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.cjs.js +7 -7
  2. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.esm.js +7 -7
  3. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.cjs.js +2 -2
  4. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.esm.js +2 -2
  5. package/admin-ui/utils/dist/nixxie-cms-core-admin-ui-utils.cjs.js +5 -5
  6. package/admin-ui/utils/dist/nixxie-cms-core-admin-ui-utils.esm.js +3 -3
  7. package/dist/{CreateItemDialog-7008b050.esm.js → CreateItemDialog-66621fe8.esm.js} +3 -3
  8. package/dist/{CreateItemDialog-a0cab315.cjs.js → CreateItemDialog-96b044ce.cjs.js} +4 -4
  9. package/dist/{Field-47f85161.esm.js → Field-1820c4e6.esm.js} +1 -0
  10. package/dist/{Field-ed8d7627.cjs.js → Field-38d3cdf9.cjs.js} +1 -0
  11. package/dist/GraphQLErrorNotice-7594a9f8.esm.js +64 -0
  12. package/dist/GraphQLErrorNotice-c8890f80.cjs.js +66 -0
  13. package/dist/{PageContainer-5ae731cc.esm.js → PageContainer-355cfbfa.esm.js} +362 -156
  14. package/dist/{PageContainer-abd7159f.cjs.js → PageContainer-4095555a.cjs.js} +361 -155
  15. package/dist/{context-af9957ed.esm.js → context-2924eaaa.esm.js} +53 -58
  16. package/dist/{context-b5204629.cjs.js → context-2ce61d0b.cjs.js} +53 -58
  17. package/dist/declarations/src/admin-ui/components/GraphQLErrorNotice.d.ts.map +1 -1
  18. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  19. package/dist/declarations/src/admin-ui/components/PageContainer.d.ts.map +1 -1
  20. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  21. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  22. package/dist/declarations/src/fields/types/bigInt/views/index.d.ts +2 -1
  23. package/dist/declarations/src/fields/types/bigInt/views/index.d.ts.map +1 -1
  24. package/dist/declarations/src/fields/types/bytes/views/index.d.ts +2 -1
  25. package/dist/declarations/src/fields/types/bytes/views/index.d.ts.map +1 -1
  26. package/dist/declarations/src/fields/types/calendarDay/views/index.d.ts.map +1 -1
  27. package/dist/declarations/src/fields/types/decimal/views/index.d.ts +2 -1
  28. package/dist/declarations/src/fields/types/decimal/views/index.d.ts.map +1 -1
  29. package/dist/declarations/src/fields/types/float/views/index.d.ts +2 -1
  30. package/dist/declarations/src/fields/types/float/views/index.d.ts.map +1 -1
  31. package/dist/declarations/src/fields/types/integer/views/index.d.ts +2 -1
  32. package/dist/declarations/src/fields/types/integer/views/index.d.ts.map +1 -1
  33. package/dist/declarations/src/fields/types/json/views/index.d.ts.map +1 -1
  34. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  35. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  36. package/dist/declarations/src/fields/types/select/views/index.d.ts.map +1 -1
  37. package/dist/declarations/src/fields/types/text/views/index.d.ts +2 -1
  38. package/dist/declarations/src/fields/types/text/views/index.d.ts.map +1 -1
  39. package/dist/declarations/src/fields/types/timestamp/views/index.d.ts.map +1 -1
  40. package/dist/declarations/src/fields/types/virtual/views/index.d.ts.map +1 -1
  41. package/dist/declarations/src/internal-unstable/admin-ui/pages/HomePage/index.d.ts.map +1 -1
  42. package/dist/declarations/src/internal-unstable/admin-ui/pages/ItemPage/index.d.ts.map +1 -1
  43. package/dist/pick-4c785a54.esm.js +34 -0
  44. package/dist/pick-906341bb.cjs.js +37 -0
  45. package/dist/{useCreateItem-1f94d252.esm.js → useCreateItem-36a75f1c.esm.js} +26 -26
  46. package/dist/{useCreateItem-1be4987e.cjs.js → useCreateItem-acf06f77.cjs.js} +37 -37
  47. package/dist/{useFilter-acc9d413.cjs.js → useFilter-c29f17a8.cjs.js} +1 -1
  48. package/dist/{useFilter-9b6db1f9.esm.js → useFilter-f79b2abb.esm.js} +1 -1
  49. package/dist/{Fields-956d9a14.esm.js → usePreventNavigation-093389dd.esm.js} +28 -2
  50. package/dist/{Fields-e2c28056.cjs.js → usePreventNavigation-d4f9f4fa.cjs.js} +27 -0
  51. package/fields/types/bigInt/views/dist/nixxie-cms-core-fields-types-bigInt-views.cjs.js +8 -0
  52. package/fields/types/bigInt/views/dist/nixxie-cms-core-fields-types-bigInt-views.esm.js +8 -1
  53. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.cjs.js +14 -3
  54. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.esm.js +15 -5
  55. package/fields/types/calendarDay/views/dist/nixxie-cms-core-fields-types-calendarDay-views.cjs.js +2 -1
  56. package/fields/types/calendarDay/views/dist/nixxie-cms-core-fields-types-calendarDay-views.esm.js +2 -1
  57. package/fields/types/decimal/views/dist/nixxie-cms-core-fields-types-decimal-views.cjs.js +10 -1
  58. package/fields/types/decimal/views/dist/nixxie-cms-core-fields-types-decimal-views.esm.js +10 -2
  59. package/fields/types/file/views/dist/nixxie-cms-core-fields-types-file-views.cjs.js +1 -1
  60. package/fields/types/file/views/dist/nixxie-cms-core-fields-types-file-views.esm.js +2 -2
  61. package/fields/types/float/views/dist/nixxie-cms-core-fields-types-float-views.cjs.js +10 -1
  62. package/fields/types/float/views/dist/nixxie-cms-core-fields-types-float-views.esm.js +10 -2
  63. package/fields/types/image/views/dist/nixxie-cms-core-fields-types-image-views.cjs.js +2 -1
  64. package/fields/types/image/views/dist/nixxie-cms-core-fields-types-image-views.esm.js +2 -1
  65. package/fields/types/integer/views/dist/nixxie-cms-core-fields-types-integer-views.cjs.js +8 -0
  66. package/fields/types/integer/views/dist/nixxie-cms-core-fields-types-integer-views.esm.js +8 -1
  67. package/fields/types/json/views/dist/nixxie-cms-core-fields-types-json-views.cjs.js +19 -4
  68. package/fields/types/json/views/dist/nixxie-cms-core-fields-types-json-views.esm.js +19 -4
  69. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +18 -3
  70. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +18 -3
  71. package/fields/types/password/views/dist/nixxie-cms-core-fields-types-password-views.cjs.js +1 -1
  72. package/fields/types/password/views/dist/nixxie-cms-core-fields-types-password-views.esm.js +1 -1
  73. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.cjs.js +9 -7
  74. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.esm.js +9 -7
  75. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.cjs.js +3 -2
  76. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.esm.js +3 -2
  77. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.cjs.js +14 -3
  78. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.esm.js +15 -5
  79. package/fields/types/timestamp/views/dist/nixxie-cms-core-fields-types-timestamp-views.cjs.js +2 -1
  80. package/fields/types/timestamp/views/dist/nixxie-cms-core-fields-types-timestamp-views.esm.js +2 -1
  81. package/fields/types/virtual/views/dist/nixxie-cms-core-fields-types-virtual-views.cjs.js +13 -1
  82. package/fields/types/virtual/views/dist/nixxie-cms-core-fields-types-virtual-views.esm.js +14 -2
  83. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.cjs.js +3 -3
  84. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.esm.js +3 -3
  85. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.cjs.js +7 -7
  86. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.esm.js +6 -6
  87. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.cjs.js +34 -33
  88. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.esm.js +35 -34
  89. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.cjs.js +53 -13
  90. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.esm.js +52 -12
  91. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.cjs.js +36 -25
  92. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.esm.js +36 -25
  93. package/package.json +2 -2
  94. package/src/admin-ui/components/CommandPalette.tsx +134 -27
  95. package/src/admin-ui/components/CreateButtonLink.tsx +20 -46
  96. package/src/admin-ui/components/GraphQLErrorNotice.tsx +39 -33
  97. package/src/admin-ui/components/Logo.tsx +5 -5
  98. package/src/admin-ui/components/Navigation.tsx +41 -27
  99. package/src/admin-ui/components/PageContainer.tsx +171 -15
  100. package/src/admin-ui/components/WelcomeDialog.tsx +14 -14
  101. package/src/admin-ui/context.tsx +5 -2
  102. package/src/admin-ui/utils/useCreateItem.ts +21 -1
  103. package/src/fields/types/bigInt/views/index.tsx +10 -1
  104. package/src/fields/types/bytes/views/index.tsx +14 -1
  105. package/src/fields/types/calendarDay/views/index.tsx +2 -1
  106. package/src/fields/types/decimal/views/index.tsx +7 -1
  107. package/src/fields/types/file/views/Field.tsx +1 -1
  108. package/src/fields/types/float/views/index.tsx +7 -1
  109. package/src/fields/types/image/views/index.tsx +1 -1
  110. package/src/fields/types/integer/views/index.tsx +5 -0
  111. package/src/fields/types/json/views/index.tsx +20 -2
  112. package/src/fields/types/multiselect/views/index.tsx +7 -3
  113. package/src/fields/types/password/views/index.tsx +1 -1
  114. package/src/fields/types/relationship/views/index.tsx +1 -0
  115. package/src/fields/types/select/views/index.tsx +2 -1
  116. package/src/fields/types/text/views/index.tsx +14 -1
  117. package/src/fields/types/timestamp/views/index.tsx +2 -1
  118. package/src/fields/types/virtual/views/index.tsx +17 -2
  119. package/src/internal-unstable/admin-ui/pages/HomePage/index.tsx +40 -31
  120. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +36 -3
  121. package/src/internal-unstable/admin-ui/pages/ListPage/PaginationControls.tsx +20 -33
  122. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +24 -16
  123. package/dist/GraphQLErrorNotice-cd74180d.cjs.js +0 -57
  124. package/dist/GraphQLErrorNotice-d9f0931b.esm.js +0 -55
  125. package/dist/pick-5fe45878.cjs.js +0 -71
  126. package/dist/pick-b7ef3115.esm.js +0 -68
@@ -1,8 +1,10 @@
1
1
  import { useState } from 'react'
2
2
 
3
3
  import { TextField } from '@keystar/ui/text-field'
4
+ import { Text } from '@keystar/ui/typography'
4
5
 
5
6
  import {
7
+ type CellComponent,
6
8
  type FieldController,
7
9
  type FieldControllerConfig,
8
10
  type FieldProps,
@@ -205,7 +207,7 @@ export function Field({
205
207
  errorMessage={(forceValidation || isDirty) && validate(value)}
206
208
  isReadOnly={isReadOnly}
207
209
  isRequired={isRequired}
208
- inputMode="numeric"
210
+ inputMode="decimal"
209
211
  width="alias.singleLineWidth"
210
212
  onBlur={() => setDirty(true)}
211
213
  onChange={x => onChange?.({ ...value, value: x === '' ? null : x })}
@@ -213,3 +215,7 @@ export function Field({
213
215
  />
214
216
  )
215
217
  }
218
+
219
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
220
+ return value != null && value !== '' ? <Text>{value}</Text> : null
221
+ }
@@ -164,7 +164,7 @@ function FileDetails(props: PropsWithChildren<FileData>) {
164
164
  gap="medium"
165
165
  padding="medium"
166
166
  >
167
- <a href={props.href} download={props.name}>
167
+ <a href={props.href} download={props.name} aria-label={`Download ${props.name}`}>
168
168
  <HStack
169
169
  alignItems="center"
170
170
  backgroundColor="surfaceTertiary"
@@ -1,8 +1,10 @@
1
1
  import { useState } from 'react'
2
2
 
3
3
  import { TextField } from '@keystar/ui/text-field'
4
+ import { Text } from '@keystar/ui/typography'
4
5
 
5
6
  import type {
7
+ CellComponent,
6
8
  FieldController,
7
9
  FieldControllerConfig,
8
10
  FieldProps,
@@ -205,7 +207,7 @@ export function Field({
205
207
  errorMessage={(forceValidation || isDirty) && validate(value)}
206
208
  isReadOnly={isReadOnly}
207
209
  isRequired={isRequired}
208
- inputMode="numeric"
210
+ inputMode="decimal"
209
211
  width="alias.singleLineWidth"
210
212
  onBlur={() => setDirty(true)}
211
213
  onChange={x => onChange?.({ ...value, value: x === '' ? null : x })}
@@ -213,3 +215,7 @@ export function Field({
213
215
  />
214
216
  )
215
217
  }
218
+
219
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
220
+ return value != null && value !== '' ? <Text>{value}</Text> : null
221
+ }
@@ -15,7 +15,7 @@ export const Cell: CellComponent<typeof controller> = ({ value }) => {
15
15
  width: 24,
16
16
  }}
17
17
  >
18
- <img style={{ maxHeight: '100%', maxWidth: '100%' }} src={value.url} />
18
+ <img alt="" style={{ maxHeight: '100%', maxWidth: '100%' }} src={value.url} />
19
19
  </div>
20
20
  )
21
21
  }
@@ -7,6 +7,7 @@ import { NumberField } from '@keystar/ui/number-field'
7
7
  import { Heading, Text } from '@keystar/ui/typography'
8
8
 
9
9
  import type {
10
+ CellComponent,
10
11
  FieldController,
11
12
  FieldControllerConfig,
12
13
  FieldProps,
@@ -253,3 +254,7 @@ export function Field({
253
254
  />
254
255
  )
255
256
  }
257
+
258
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
259
+ return typeof value === 'number' && Number.isFinite(value) ? <Text>{value}</Text> : null
260
+ }
@@ -1,3 +1,5 @@
1
+ import { useState } from 'react'
2
+
1
3
  import { css, tokenSchema } from '@keystar/ui/style'
2
4
  import { TextArea } from '@keystar/ui/text-field'
3
5
  import { Text } from '@keystar/ui/typography'
@@ -12,7 +14,9 @@ import type {
12
14
 
13
15
  export const Field = (props: FieldProps<typeof controller>) => {
14
16
  const { autoFocus, field, forceValidation, onChange, value } = props
15
- const errorMessage = forceValidation ? 'Invalid JSON' : undefined
17
+ const [isDirty, setDirty] = useState(false)
18
+ const errorMessage =
19
+ (isDirty || forceValidation) && !isValidJSON(value) ? 'Invalid JSON' : undefined
16
20
 
17
21
  return (
18
22
  <TextArea
@@ -22,6 +26,7 @@ export const Field = (props: FieldProps<typeof controller>) => {
22
26
  isReadOnly={onChange === undefined}
23
27
  label={field.label}
24
28
  onChange={onChange}
29
+ onBlur={() => setDirty(true)}
25
30
  value={value}
26
31
  UNSAFE_className={css({
27
32
  textarea: {
@@ -33,8 +38,21 @@ export const Field = (props: FieldProps<typeof controller>) => {
33
38
  )
34
39
  }
35
40
 
41
+ function isValidJSON(value: string) {
42
+ if (!value) return true
43
+ try {
44
+ JSON.parse(value)
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
36
51
  export const Cell: CellComponent<typeof controller> = ({ value }) => {
37
- return value ? <Text>{JSON.stringify(value)}</Text> : null
52
+ if (value == null) return null
53
+ const stringified = JSON.stringify(value)
54
+ const truncated = stringified.length > 100 ? `${stringified.slice(0, 100)}…` : stringified
55
+ return <Text>{truncated}</Text>
38
56
  }
39
57
 
40
58
  type Config = FieldControllerConfig<{ defaultValue: JSONValue }>
@@ -89,7 +89,7 @@ function CheckboxesModeField(props: FieldProps<typeof controller>) {
89
89
 
90
90
  export const Cell: CellComponent<typeof controller> = ({ value = [], field }) => {
91
91
  const listFormatter = useListFormatter({ style: 'short', type: 'conjunction' })
92
- const labels = (value as string[]).map(x => field.valuesToOptionsWithStringValues[x].label)
92
+ const labels = (value as string[]).map(x => field.valuesToOptionsWithStringValues[x]?.label ?? x)
93
93
 
94
94
  const cellContent =
95
95
  value.length > 3
@@ -135,12 +135,16 @@ export function controller(config: Config): FieldController<Value, Option[]> & {
135
135
  type: config.fieldMeta.type,
136
136
  options: optionsWithStringValues,
137
137
  valuesToOptionsWithStringValues,
138
- defaultValue: config.fieldMeta.defaultValue.map(x => valuesToOptionsWithStringValues[x]),
138
+ defaultValue: config.fieldMeta.defaultValue.map(
139
+ x => valuesToOptionsWithStringValues[x] ?? { label: x.toString(), value: x.toString() }
140
+ ),
139
141
  deserialize: data => {
140
142
  // if we get null from the GraphQL API (which will only happen if field read access control failed)
141
143
  // we'll just show it as nothing being selected for now.
142
144
  const values: readonly string[] | readonly number[] = data[config.fieldKey] ?? []
143
- const selectedOptions = values.map(x => valuesToOptionsWithStringValues[x])
145
+ const selectedOptions = values.map(
146
+ x => valuesToOptionsWithStringValues[x] ?? { label: x.toString(), value: x.toString() }
147
+ )
144
148
  return selectedOptions
145
149
  },
146
150
  serialize: value => ({ [config.fieldKey]: value.map(x => parseValue(x.value)) }),
@@ -181,7 +181,7 @@ export function Field(props: FieldProps<typeof controller>) {
181
181
 
182
182
  <Flex gap="regular">
183
183
  <ToggleButton
184
- aria-label="show"
184
+ aria-label={secureTextEntry ? 'Show password' : 'Hide password'}
185
185
  isSelected={!secureTextEntry}
186
186
  onPress={() => setSecureTextEntry(bool => !bool)}
187
187
  >
@@ -58,6 +58,7 @@ export function Field(props: FieldProps<typeof controller>) {
58
58
  <HStack gap="small" alignItems="end">
59
59
  {textField}
60
60
  <ActionButton
61
+ aria-label={`Go to ${field.label}`}
61
62
  href={`/${foreignList.path}?${buildQueryForRelationshipFieldWithForeignField(foreignList, field.refFieldKey, value.id)}`}
62
63
  >
63
64
  <Icon src={arrowUpRightIcon} />
@@ -148,8 +148,9 @@ export function Field(props: FieldProps<typeof controller>) {
148
148
  }
149
149
 
150
150
  export const Cell: CellComponent<typeof controller> = ({ value, field }) => {
151
+ if (value == null) return null
151
152
  const label = field.options.find(x => x.value === value)?.label
152
- return <Text>{label}</Text>
153
+ return <Text>{label ?? String(value)}</Text>
153
154
  }
154
155
 
155
156
  export type AdminSelectFieldMeta = {
@@ -1,10 +1,12 @@
1
1
  import { useState } from 'react'
2
2
  import { TextArea, TextField } from '@keystar/ui/text-field'
3
+ import { Text } from '@keystar/ui/typography'
3
4
 
4
5
  import type { TextFieldMeta } from '..'
5
6
  import { NullableFieldWrapper } from '../../../../admin-ui/components'
6
7
  import { entriesTyped } from '../../../../lib/core/utils'
7
8
  import type {
9
+ CellComponent,
8
10
  FieldController,
9
11
  FieldControllerConfig,
10
12
  FieldProps,
@@ -46,7 +48,14 @@ export function Field(props: FieldProps<typeof controller>) {
46
48
  label={field.label}
47
49
  errorMessage={
48
50
  !!validationMessages.length && (shouldShowErrors || forceValidation)
49
- ? validationMessages.join('. ')
51
+ ? validationMessages.length === 1
52
+ ? validationMessages[0]
53
+ : validationMessages.map((message, i) => (
54
+ <Text key={i}>
55
+ {i > 0 && <br />}
56
+ {message}
57
+ </Text>
58
+ ))
50
59
  : undefined
51
60
  }
52
61
  isDisabled={isNull}
@@ -73,6 +82,10 @@ export function Field(props: FieldProps<typeof controller>) {
73
82
  )
74
83
  }
75
84
 
85
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
86
+ return value ? <Text truncate>{value}</Text> : null
87
+ }
88
+
76
89
  type Config = FieldControllerConfig<TextFieldMeta>
77
90
 
78
91
  type Validation = {
@@ -44,12 +44,13 @@ export function Field(props: FieldProps<typeof controller>) {
44
44
  description={field.description}
45
45
  isDisabled={!parsedValue}
46
46
  isReadOnly
47
+ placeholder="yyyy-mm-dd --:--:--"
47
48
  value={
48
49
  parsedValue
49
50
  ? isReadonlyUTC
50
51
  ? parsedValue.toAbsoluteString()
51
52
  : dateFormatter.format(parsedValue.toDate())
52
- : 'yyyy-mm-dd --:--:--'
53
+ : ''
53
54
  }
54
55
  />
55
56
  {!!parsedValue && (
@@ -1,4 +1,4 @@
1
- import { TextField } from '@keystar/ui/text-field'
1
+ import { TextArea, TextField } from '@keystar/ui/text-field'
2
2
  import { Text } from '@keystar/ui/typography'
3
3
  import type {
4
4
  CellComponent,
@@ -21,13 +21,28 @@ export function Field(props: FieldProps<typeof controller>) {
21
21
  const { autoFocus, field, value } = props
22
22
  if (value === createViewValue) return null
23
23
 
24
+ const stringified = stringify(value)
25
+ // multi-line values (e.g. pretty-printed JSON) get clipped in a single-line
26
+ // TextField, so render them in a read-only TextArea instead
27
+ if (stringified.includes('\n')) {
28
+ return (
29
+ <TextArea
30
+ autoFocus={autoFocus}
31
+ description={field.description}
32
+ label={field.label}
33
+ isReadOnly={true}
34
+ value={stringified}
35
+ />
36
+ )
37
+ }
38
+
24
39
  return (
25
40
  <TextField
26
41
  autoFocus={autoFocus}
27
42
  description={field.description}
28
43
  label={field.label}
29
44
  isReadOnly={true}
30
- value={stringify(value)}
45
+ value={stringified}
31
46
  />
32
47
  )
33
48
  }
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react'
2
2
 
3
- import { css } from '@keystar/ui/style'
3
+ import { css, tokenSchema } from '@keystar/ui/style'
4
4
  import { Heading } from '@keystar/ui/typography'
5
5
 
6
6
  import { gql, type TypedDocumentNode, useQuery } from '../../../../admin-ui/apollo'
@@ -24,14 +24,14 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
24
24
  <div
25
25
  className={css({
26
26
  position: 'relative',
27
- backgroundColor: '#ffffff',
28
- border: '1px solid #ebebeb',
27
+ backgroundColor: tokenSchema.color.background.canvas,
28
+ border: `1px solid ${tokenSchema.color.border.muted}`,
29
29
  borderRadius: 8,
30
30
  overflow: 'hidden',
31
31
  transition: 'box-shadow 180ms, border-color 180ms',
32
32
  '&:hover': {
33
- boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
34
- borderColor: '#d8d8d8',
33
+ boxShadow: `0 2px 12px ${tokenSchema.color.shadow.muted}`,
34
+ borderColor: tokenSchema.color.border.emphasis,
35
35
  },
36
36
  })}
37
37
  >
@@ -44,7 +44,7 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
44
44
  fontWeight: 600,
45
45
  letterSpacing: '0.09em',
46
46
  textTransform: 'uppercase',
47
- color: '#a3a3a3',
47
+ color: tokenSchema.color.foreground.neutralSecondary,
48
48
  display: 'flex',
49
49
  alignItems: 'center',
50
50
  justifyContent: 'space-between',
@@ -69,11 +69,11 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
69
69
  display: 'inline-flex',
70
70
  alignItems: 'center',
71
71
  justifyContent: 'center',
72
- width: 22,
73
- height: 22,
72
+ width: 28,
73
+ height: 28,
74
74
  borderRadius: 5,
75
- border: '1px solid #ebebeb',
76
- color: '#a3a3a3',
75
+ border: `1px solid ${tokenSchema.color.border.muted}`,
76
+ color: tokenSchema.color.foreground.neutralSecondary,
77
77
  textDecoration: 'none',
78
78
  fontSize: 14,
79
79
  lineHeight: 1,
@@ -81,9 +81,9 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
81
81
  marginLeft: 6,
82
82
  transition: 'border-color 130ms, color 130ms, background 130ms',
83
83
  '&:hover': {
84
- borderColor: '#000000',
85
- color: '#000000',
86
- background: '#f5f5f5',
84
+ borderColor: tokenSchema.color.scale.black,
85
+ color: tokenSchema.color.foreground.neutralEmphasis,
86
+ background: tokenSchema.color.background.surfaceSecondary,
87
87
  },
88
88
  })}
89
89
  >
@@ -111,7 +111,10 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
111
111
  fontSize: 34,
112
112
  fontWeight: 700,
113
113
  letterSpacing: '-0.05em',
114
- color: count === null ? '#e8e8e8' : '#000000',
114
+ color:
115
+ count === null
116
+ ? tokenSchema.color.border.muted
117
+ : tokenSchema.color.foreground.neutralEmphasis,
115
118
  lineHeight: 1,
116
119
  fontVariantNumeric: 'tabular-nums',
117
120
  })}
@@ -123,12 +126,12 @@ function CollectionCard({ listKey, count, href, createHref }: CardProps) {
123
126
  className={css({
124
127
  margin: 0,
125
128
  fontSize: 12,
126
- color: '#c8c8c8',
129
+ color: tokenSchema.color.foreground.neutralSecondary,
127
130
  display: 'flex',
128
131
  alignItems: 'center',
129
132
  gap: 4,
130
133
  transition: 'color 130ms',
131
- 'a:hover &': { color: '#636363' },
134
+ 'a:hover &': { color: tokenSchema.color.foreground.neutralEmphasis },
132
135
  })}
133
136
  >
134
137
  {list.isSingleton ? 'Open' : 'View all'}
@@ -167,7 +170,7 @@ function QuickCreate() {
167
170
  fontWeight: 600,
168
171
  letterSpacing: '0.10em',
169
172
  textTransform: 'uppercase',
170
- color: '#b8b8b8',
173
+ color: tokenSchema.color.foreground.neutralSecondary,
171
174
  })}
172
175
  >
173
176
  Quick Create
@@ -184,18 +187,18 @@ function QuickCreate() {
184
187
  paddingInline: '12px',
185
188
  paddingBlock: '6px',
186
189
  borderRadius: 6,
187
- border: '1px solid #e8e8e8',
188
- backgroundColor: '#ffffff',
190
+ border: `1px solid ${tokenSchema.color.border.muted}`,
191
+ backgroundColor: tokenSchema.color.background.canvas,
189
192
  fontSize: 12.5,
190
193
  fontWeight: 500,
191
- color: '#525252',
194
+ color: tokenSchema.color.foreground.neutral,
192
195
  textDecoration: 'none',
193
196
  transition: 'border-color 130ms, color 130ms, background 130ms',
194
197
 
195
198
  '&:hover': {
196
- borderColor: '#000000',
197
- color: '#000000',
198
- background: '#fafafa',
199
+ borderColor: tokenSchema.color.scale.black,
200
+ color: tokenSchema.color.foreground.neutralEmphasis,
201
+ background: tokenSchema.color.background.surface,
199
202
  },
200
203
  })}
201
204
  >
@@ -260,18 +263,24 @@ export function HomePage() {
260
263
 
261
264
  {/* Page title */}
262
265
  <div className={css({ marginBottom: 28 })}>
263
- <h1
266
+ <h2
264
267
  className={css({
265
268
  margin: '0 0 4px',
266
269
  fontSize: 20,
267
270
  fontWeight: 700,
268
271
  letterSpacing: '-0.03em',
269
- color: '#0a0a0a',
272
+ color: tokenSchema.color.foreground.neutralEmphasis,
270
273
  })}
271
274
  >
272
275
  Overview
273
- </h1>
274
- <p className={css({ margin: 0, fontSize: 13, color: '#a3a3a3' })}>
276
+ </h2>
277
+ <p
278
+ className={css({
279
+ margin: 0,
280
+ fontSize: 13,
281
+ color: tokenSchema.color.foreground.neutralSecondary,
282
+ })}
283
+ >
275
284
  {visibleLists.length} collection{visibleLists.length !== 1 ? 's' : ''}
276
285
  </p>
277
286
  </div>
@@ -305,7 +314,7 @@ export function HomePage() {
305
314
  className={css({
306
315
  marginTop: 48,
307
316
  fontSize: 11.5,
308
- color: '#d4d4d4',
317
+ color: tokenSchema.color.foreground.neutralSecondary,
309
318
  display: 'flex',
310
319
  alignItems: 'center',
311
320
  gap: 6,
@@ -315,12 +324,12 @@ export function HomePage() {
315
324
  className={css({
316
325
  display: 'inline-flex',
317
326
  padding: '1px 6px',
318
- background: '#ffffff',
319
- border: '1px solid #ebebeb',
327
+ background: tokenSchema.color.background.canvas,
328
+ border: `1px solid ${tokenSchema.color.border.muted}`,
320
329
  borderRadius: 4,
321
330
  fontSize: 10.5,
322
331
  fontFamily: 'inherit',
323
- color: '#a3a3a3',
332
+ color: tokenSchema.color.foreground.neutralSecondary,
324
333
  })}
325
334
  >
326
335
  ⌘K
@@ -38,6 +38,7 @@ import {
38
38
  useInvalidFields,
39
39
  } from '../../../../admin-ui/utils'
40
40
  import { pick } from '../../../../admin-ui/utils/pick'
41
+ import { usePreventNavigation } from '../../../../admin-ui/utils/usePreventNavigation'
41
42
  import type {
42
43
  ActionMeta,
43
44
  BaseCollectionTypeInfo,
@@ -62,6 +63,22 @@ function useEventCallback<Func extends (...args: any[]) => unknown>(callback: Fu
62
63
  return cb as any
63
64
  }
64
65
 
66
+ // after forcing validation, move focus to the first invalid field so the user
67
+ // can see and fix it. runs on the next frame so the DOM reflects the new
68
+ // aria-invalid state, and is fully defensive so it can never throw.
69
+ function focusFirstInvalidField() {
70
+ requestAnimationFrame(() => {
71
+ try {
72
+ const el = document.querySelector<HTMLElement>('[aria-invalid="true"]')
73
+ if (!el) return
74
+ el.focus({ preventScroll: false })
75
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' })
76
+ } catch {
77
+ // ignore — focusing is a best-effort enhancement
78
+ }
79
+ })
80
+ }
81
+
65
82
  function DeleteButton({
66
83
  list,
67
84
  itemId,
@@ -155,7 +172,6 @@ function ResetButton(props: { onReset: () => void; hasChanges?: boolean }) {
155
172
  title="Reset changes"
156
173
  cancelLabel="Cancel"
157
174
  primaryActionLabel="Yes, reset"
158
- autoFocusButton="primary"
159
175
  onPrimaryAction={props.onReset}
160
176
  >
161
177
  Are you sure? Any unsaved changes will be lost and cannot be recovered.
@@ -211,7 +227,11 @@ function ItemForm({
211
227
  e.preventDefault()
212
228
  const newForceValidation = invalidFields.size !== 0
213
229
  setForceValidation(newForceValidation)
214
- if (newForceValidation) return
230
+ if (newForceValidation) {
231
+ toastQueue.critical('Please fix the highlighted field(s) before saving.')
232
+ focusFirstInvalidField()
233
+ return
234
+ }
215
235
 
216
236
  const { error: _error } = await update({
217
237
  variables: {
@@ -236,10 +256,18 @@ function ItemForm({
236
256
  timeout: 5000,
237
257
  })
238
258
 
259
+ // reset the navigation guard before refetch so saving and then navigating
260
+ // away doesn't prompt for unsaved changes
261
+ shouldPreventNavigationRef.current = false
239
262
  onSaveSuccess()
240
263
  })
241
264
 
242
265
  const hasChangedFields = useHasChanges('update', list.fields, value, initialValue)
266
+ const shouldPreventNavigationRef = useRef(hasChangedFields)
267
+ useEffect(() => {
268
+ shouldPreventNavigationRef.current = hasChangedFields
269
+ }, [hasChangedFields])
270
+ usePreventNavigation(shouldPreventNavigationRef)
243
271
 
244
272
  return (
245
273
  <Fragment>
@@ -324,7 +352,12 @@ function ItemPage({ listKey }: ItemPageProps) {
324
352
  const itemLabel_ = item?.[list.labelField] ?? item?.id
325
353
  const itemLabel = typeof itemLabel_ === 'string' ? itemLabel_ : (itemId ?? '')
326
354
 
327
- const pageLoading = loading || itemId === undefined
355
+ // Only show the full-page spinner on the INITIAL load (no data yet). On a
356
+ // post-save refetch Apollo keeps `data` populated while `loading` flips back
357
+ // to true; gating on `!data` keeps the form mounted (no scroll reset / flash)
358
+ // during background refetches while still showing the spinner before the
359
+ // first result arrives.
360
+ const pageLoading = (loading && !data) || itemId === undefined
328
361
  const pageLabel = itemLabel || itemId
329
362
  const pageTitle = list.isSingleton || typeof pageLabel !== 'string' ? list.label : pageLabel
330
363
  const initialValue = useMemo(() => {
@@ -4,17 +4,11 @@ import { chevronLeftIcon } from '@keystar/ui/icon/icons/chevronLeftIcon'
4
4
  import { chevronRightIcon } from '@keystar/ui/icon/icons/chevronRightIcon'
5
5
  import { undo2Icon } from '@keystar/ui/icon/icons/undo2Icon'
6
6
  import { HStack } from '@keystar/ui/layout'
7
- import { Picker } from '@keystar/ui/picker'
8
- import { Item } from '@keystar/ui/tag'
7
+ import { NumberField } from '@keystar/ui/number-field'
8
+ import { Item, Picker } from '@keystar/ui/picker'
9
9
  import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'
10
10
  import { Text } from '@keystar/ui/typography'
11
11
  import type { ReactNode } from 'react'
12
- import { useMemo } from 'react'
13
-
14
- type PageItem = {
15
- label: string
16
- id: number
17
- }
18
12
 
19
13
  export function PaginationControls(props: {
20
14
  singular: string
@@ -34,16 +28,11 @@ export function PaginationControls(props: {
34
28
  const prevPage = currentPage - 1
35
29
  const lastPage = Math.max(Math.ceil(total / pageSize), 1)
36
30
 
37
- const pageItems = useMemo(() => {
38
- const result: PageItem[] = []
39
- for (let page = 1; page <= lastPage; page++) {
40
- result.push({
41
- id: page,
42
- label: String(page),
43
- })
44
- }
45
- return result
46
- }, [lastPage])
31
+ const goToPage = (value: number) => {
32
+ if (!Number.isFinite(value)) return
33
+ const page = Math.min(Math.max(Math.round(value), 1), lastPage)
34
+ if (page !== currentPage) props.onChangePage(page)
35
+ }
47
36
 
48
37
  return (
49
38
  <HStack
@@ -90,24 +79,22 @@ export function PaginationControls(props: {
90
79
 
91
80
  {/*
92
81
  right-side
93
- mobile: next/prev
94
- desktop^: current page (picker), next/prev
82
+ mobile: page jump, next/prev
83
+ desktop^: page jump, next/prev
95
84
  */}
96
85
  <HStack gap="large" alignItems="center">
97
- <HStack isHidden={{ below: 'desktop' }} gap="regular" alignItems="center">
98
- <Picker
99
- // prominence="low"
100
- aria-label={`Page number, of ${lastPage} pages`}
101
- items={pageItems}
102
- onSelectionChange={page => {
103
- props.onChangePage(Number(page))
104
- }}
105
- selectedKey={currentPage}
86
+ <HStack gap="regular" alignItems="center">
87
+ <NumberField
88
+ aria-label="Go to page"
89
+ hideStepper
90
+ minValue={1}
91
+ maxValue={lastPage}
92
+ step={1}
93
+ value={currentPage}
94
+ onChange={goToPage}
106
95
  width="scale.1000"
107
- >
108
- {item => <Item>{item.label}</Item>}
109
- </Picker>
110
- <Text>of {lastPage} pages</Text>
96
+ />
97
+ <Text>of {lastPage}</Text>
111
98
  </HStack>
112
99
  <HStack gap="regular">
113
100
  <ActionButton