@nixxie-cms/core 1.0.0 → 1.0.2

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 (187) hide show
  1. package/README.md +2 -2
  2. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.cjs.js +4 -4
  3. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.esm.js +4 -4
  4. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.cjs.js +2 -2
  5. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.esm.js +2 -2
  6. package/context/dist/nixxie-cms-core-context.cjs.js +2 -2
  7. package/context/dist/nixxie-cms-core-context.esm.js +2 -2
  8. package/dist/{CreateItemDialog-33335548.esm.js → CreateItemDialog-7008b050.esm.js} +1 -1
  9. package/dist/{CreateItemDialog-56cf59b7.cjs.js → CreateItemDialog-a0cab315.cjs.js} +1 -1
  10. package/dist/{PageContainer-7db73317.esm.js → PageContainer-5ae731cc.esm.js} +25 -18
  11. package/dist/{PageContainer-27c27f10.cjs.js → PageContainer-abd7159f.cjs.js} +25 -18
  12. package/dist/{admin-meta-graphql-6f7f5331.esm.js → admin-meta-graphql-0e6e606e.esm.js} +1 -1
  13. package/dist/{admin-meta-graphql-c8f926e9.cjs.js → admin-meta-graphql-306c224a.cjs.js} +1 -1
  14. package/dist/{context-3132c3ed.esm.js → context-af9957ed.esm.js} +2 -2
  15. package/dist/{context-e7a45152.cjs.js → context-b5204629.cjs.js} +2 -2
  16. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  17. package/dist/declarations/src/admin-ui/components/PageContainer.d.ts.map +1 -1
  18. package/dist/declarations/src/helpers.d.ts.map +1 -1
  19. package/dist/declarations/src/index.d.ts +1 -0
  20. package/dist/declarations/src/index.d.ts.map +1 -1
  21. package/dist/declarations/src/internal-unstable/admin-ui/id-field-view.d.ts.map +1 -0
  22. package/dist/declarations/src/internal-unstable/admin-ui/pages/App/index.d.ts.map +1 -0
  23. package/dist/declarations/src/internal-unstable/admin-ui/pages/CreateItemPage/index.d.ts.map +1 -0
  24. package/dist/declarations/src/internal-unstable/admin-ui/pages/HomePage/index.d.ts.map +1 -0
  25. package/dist/declarations/src/internal-unstable/admin-ui/pages/ItemPage/index.d.ts.map +1 -0
  26. package/dist/declarations/src/internal-unstable/admin-ui/pages/ListPage/index.d.ts.map +1 -0
  27. package/dist/declarations/src/internal-unstable/admin-ui/pages/NoAccessPage/index.d.ts.map +1 -0
  28. package/dist/declarations/src/internal-unstable/artifacts.d.ts.map +1 -0
  29. package/dist/declarations/src/lib/core/initialise-lists.d.ts +1 -1
  30. package/dist/declarations/src/schema.d.ts.map +1 -1
  31. package/dist/declarations/src/types/config/index.d.ts +60 -1
  32. package/dist/declarations/src/types/config/index.d.ts.map +1 -1
  33. package/dist/declarations/src/types/config/lists.d.ts +4 -4
  34. package/dist/declarations/src/types/context.d.ts +150 -0
  35. package/dist/declarations/src/types/context.d.ts.map +1 -1
  36. package/dist/declarations/src/types/next-fields.d.ts +1 -1
  37. package/dist/{express-e9ed9a7d.cjs.js → express-455ae20c.cjs.js} +1 -1
  38. package/dist/{express-6743b918.esm.js → express-7559ca2d.esm.js} +1 -1
  39. package/dist/{index-ac01583b.cjs.js → index-89635494.cjs.js} +4 -4
  40. package/dist/{index-24b78415.esm.js → index-baa799e0.esm.js} +4 -4
  41. package/dist/nixxie-cms-core.cjs.js +104 -77
  42. package/dist/nixxie-cms-core.esm.js +104 -77
  43. package/dist/{non-null-graphql-5315718c.esm.js → non-null-graphql-a84ed64d.esm.js} +1 -1
  44. package/dist/{non-null-graphql-17b83ddc.cjs.js → non-null-graphql-add6bb3d.cjs.js} +1 -1
  45. package/dist/{resolve-hooks-66fe8a8e.cjs.js → resolve-hooks-165a9ce2.cjs.js} +1 -1
  46. package/dist/{resolve-hooks-17aafd37.esm.js → resolve-hooks-6813a045.esm.js} +2 -2
  47. package/dist/{system-dfec2f0a.esm.js → system-03e49e4f.esm.js} +8 -4
  48. package/dist/{system-48c5f6df.cjs.js → system-a321642d.cjs.js} +8 -4
  49. package/dist/{useFilter-0b5a1ee6.esm.js → useFilter-9b6db1f9.esm.js} +1 -1
  50. package/dist/{useFilter-1a4e6900.cjs.js → useFilter-acc9d413.cjs.js} +1 -1
  51. package/fields/dist/nixxie-cms-core-fields.cjs.js +16 -16
  52. package/fields/dist/nixxie-cms-core-fields.esm.js +17 -17
  53. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.cjs.js +3 -3
  54. package/fields/types/bytes/dist/nixxie-cms-core-fields-types-bytes.esm.js +3 -3
  55. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.cjs.js +1 -1
  56. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.esm.js +1 -1
  57. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.cjs.js +3 -3
  58. package/fields/types/password/dist/nixxie-cms-core-fields-types-password.esm.js +3 -3
  59. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.cjs.js +4 -4
  60. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.esm.js +4 -4
  61. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.cjs.js +1 -1
  62. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.esm.js +1 -1
  63. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.cjs.js +1 -1
  64. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.esm.js +1 -1
  65. package/internal-unstable/admin-ui/id-field-view/dist/nixxie-cms-core-internal-unstable-admin-ui-id-field-view.cjs.d.ts +2 -0
  66. package/internal-unstable/admin-ui/id-field-view/dist/nixxie-cms-core-internal-unstable-admin-ui-id-field-view.cjs.js +244 -0
  67. package/internal-unstable/admin-ui/id-field-view/dist/nixxie-cms-core-internal-unstable-admin-ui-id-field-view.esm.js +235 -0
  68. package/internal-unstable/admin-ui/id-field-view/package.json +4 -0
  69. package/internal-unstable/admin-ui/next-config/package.json +4 -0
  70. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.cjs.d.ts +2 -0
  71. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.cjs.js +59 -0
  72. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.esm.js +55 -0
  73. package/internal-unstable/admin-ui/pages/App/package.json +4 -0
  74. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.cjs.d.ts +2 -0
  75. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.cjs.js +116 -0
  76. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.esm.js +112 -0
  77. package/internal-unstable/admin-ui/pages/CreateItemPage/package.json +4 -0
  78. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.cjs.d.ts +2 -0
  79. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.cjs.js +336 -0
  80. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.esm.js +332 -0
  81. package/internal-unstable/admin-ui/pages/HomePage/package.json +4 -0
  82. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.cjs.d.ts +2 -0
  83. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.cjs.js +463 -0
  84. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.esm.js +455 -0
  85. package/internal-unstable/admin-ui/pages/ItemPage/package.json +4 -0
  86. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.cjs.d.ts +2 -0
  87. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.cjs.js +1195 -0
  88. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.esm.js +1187 -0
  89. package/internal-unstable/admin-ui/pages/ListPage/package.json +4 -0
  90. package/internal-unstable/admin-ui/pages/NoAccessPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-NoAccessPage.cjs.d.ts +2 -0
  91. package/internal-unstable/admin-ui/pages/NoAccessPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-NoAccessPage.cjs.js +40 -0
  92. package/internal-unstable/admin-ui/pages/NoAccessPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-NoAccessPage.esm.js +35 -0
  93. package/internal-unstable/admin-ui/pages/NoAccessPage/package.json +4 -0
  94. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.d.ts +2 -0
  95. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.cjs.js +51 -0
  96. package/internal-unstable/artifacts/dist/nixxie-cms-core-internal-unstable-artifacts.esm.js +38 -0
  97. package/internal-unstable/artifacts/package.json +4 -0
  98. package/package.json +44 -44
  99. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.cjs.js +44 -15
  100. package/scripts/cli/dist/nixxie-cms-core-scripts-cli.esm.js +44 -15
  101. package/scripts/dist/nixxie-cms-core-scripts.cjs.js +3 -3
  102. package/scripts/dist/nixxie-cms-core-scripts.esm.js +3 -3
  103. package/src/admin-ui/admin-meta-graphql.ts +168 -168
  104. package/src/admin-ui/components/CommandPalette.tsx +433 -431
  105. package/src/admin-ui/components/Navigation.tsx +389 -385
  106. package/src/admin-ui/components/PageContainer.tsx +311 -310
  107. package/src/admin-ui/components/WelcomeDialog.tsx +1 -1
  108. package/src/admin-ui/context.tsx +338 -338
  109. package/src/admin-ui/templates/app.ts +60 -60
  110. package/src/admin-ui/templates/create-item.ts +5 -5
  111. package/src/admin-ui/templates/home.ts +2 -2
  112. package/src/admin-ui/templates/item.tsx +5 -5
  113. package/src/admin-ui/templates/list.tsx +5 -5
  114. package/src/admin-ui/templates/next-config.ts +29 -0
  115. package/src/admin-ui/templates/no-access.ts +7 -7
  116. package/src/fields/types/bigInt/index.ts +181 -181
  117. package/src/fields/types/bytes/index.ts +275 -275
  118. package/src/fields/types/calendarDay/index.ts +194 -194
  119. package/src/fields/types/checkbox/index.ts +76 -76
  120. package/src/fields/types/decimal/index.ts +182 -182
  121. package/src/fields/types/file/index.ts +168 -168
  122. package/src/fields/types/float/index.ts +133 -133
  123. package/src/fields/types/image/index.ts +244 -244
  124. package/src/fields/types/integer/index.ts +156 -156
  125. package/src/fields/types/json/index.ts +77 -77
  126. package/src/fields/types/multiselect/index.ts +212 -212
  127. package/src/fields/types/password/index.ts +241 -241
  128. package/src/fields/types/relationship/index.ts +381 -381
  129. package/src/fields/types/relationship/views/RelationshipTable.tsx +190 -190
  130. package/src/fields/types/select/index.ts +226 -226
  131. package/src/fields/types/text/index.ts +207 -207
  132. package/src/fields/types/timestamp/index.ts +116 -116
  133. package/src/fields/types/virtual/index.ts +108 -108
  134. package/src/helpers.ts +342 -316
  135. package/src/index.ts +4 -0
  136. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/id-field-view.tsx +167 -167
  137. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/App/index.tsx +22 -22
  138. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/CreateItemPage/index.tsx +71 -71
  139. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/HomePage/index.tsx +333 -333
  140. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ItemPage/common.tsx +358 -358
  141. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ItemPage/index.tsx +483 -483
  142. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ListPage/FilterAdd.tsx +221 -221
  143. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ListPage/PaginationControls.tsx +170 -170
  144. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ListPage/Tag.tsx +72 -72
  145. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ListPage/index.tsx +1006 -1006
  146. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/NoAccessPage/index.tsx +24 -24
  147. package/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/artifacts.ts +5 -5
  148. package/src/lib/context/createContext.ts +165 -161
  149. package/src/lib/core/initialise-lists.ts +1097 -1097
  150. package/src/lib/id-field.ts +214 -214
  151. package/src/lib/telemetry.ts +342 -342
  152. package/src/schema.ts +237 -233
  153. package/src/scripts/telemetry.ts +1 -1
  154. package/src/types/config/index.ts +400 -333
  155. package/src/types/config/lists.ts +4 -4
  156. package/src/types/context.ts +700 -530
  157. package/src/types/next-fields.ts +499 -499
  158. package/src/types/telemetry.ts +51 -51
  159. package/tests/telemetry.test.ts +361 -361
  160. package/CHANGELOG.md +0 -3158
  161. package/___internal-do-not-use-will-break-in-patch/admin-ui/id-field-view/package.json +0 -4
  162. package/___internal-do-not-use-will-break-in-patch/admin-ui/next-config/package.json +0 -4
  163. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/package.json +0 -4
  164. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/CreateItemPage/package.json +0 -4
  165. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/HomePage/package.json +0 -4
  166. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ItemPage/package.json +0 -4
  167. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/package.json +0 -4
  168. package/___internal-do-not-use-will-break-in-patch/admin-ui/pages/NoAccessPage/package.json +0 -4
  169. package/___internal-do-not-use-will-break-in-patch/artifacts/package.json +0 -4
  170. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/id-field-view.d.ts.map +0 -1
  171. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/App/index.d.ts.map +0 -1
  172. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/CreateItemPage/index.d.ts.map +0 -1
  173. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/HomePage/index.d.ts.map +0 -1
  174. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ItemPage/index.d.ts.map +0 -1
  175. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ListPage/index.d.ts.map +0 -1
  176. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/NoAccessPage/index.d.ts.map +0 -1
  177. package/dist/declarations/src/___internal-do-not-use-will-break-in-patch/artifacts.d.ts.map +0 -1
  178. /package/dist/{common-1a350e11.cjs.js → common-5933f758.cjs.js} +0 -0
  179. /package/dist/{common-29fc82e6.esm.js → common-ea5c441a.esm.js} +0 -0
  180. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/id-field-view.d.ts +0 -0
  181. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/App/index.d.ts +0 -0
  182. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/CreateItemPage/index.d.ts +0 -0
  183. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/HomePage/index.d.ts +0 -0
  184. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ItemPage/index.d.ts +0 -0
  185. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/ListPage/index.d.ts +0 -0
  186. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/admin-ui/pages/NoAccessPage/index.d.ts +0 -0
  187. /package/dist/declarations/src/{___internal-do-not-use-will-break-in-patch → internal-unstable}/artifacts.d.ts +0 -0
@@ -1,1006 +1,1006 @@
1
- import isDeepEqual from 'fast-deep-equal'
2
- import type { GraphQLFormattedError } from 'graphql'
3
- import { useRouter } from 'next/router'
4
- import type { ParsedUrlQuery, ParsedUrlQueryInput } from 'querystring'
5
- import { type FormEvent, type Key, Fragment, useEffect, useId, useMemo, useState } from 'react'
6
-
7
- import { ActionBar, ActionBarContainer, Item } from '@keystar/ui/action-bar'
8
- import { ActionButton, Button, ButtonGroup } from '@keystar/ui/button'
9
- import { AlertDialog, Dialog, DialogContainer, DialogTrigger } from '@keystar/ui/dialog'
10
- import { Icon } from '@keystar/ui/icon'
11
- import { allIcons as KeystarIcons } from '@keystar/ui/icon/all'
12
- import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'
13
- import { searchXIcon } from '@keystar/ui/icon/icons/searchXIcon'
14
- import { textSelectIcon } from '@keystar/ui/icon/icons/textSelectIcon'
15
- import { undo2Icon } from '@keystar/ui/icon/icons/undo2Icon'
16
- import { Box, Flex, HStack, VStack } from '@keystar/ui/layout'
17
- import { Menu, MenuTrigger } from '@keystar/ui/menu'
18
- import { ProgressCircle } from '@keystar/ui/progress'
19
- import { SearchField } from '@keystar/ui/search-field'
20
- import { Content } from '@keystar/ui/slots'
21
- import { css, tokenSchema } from '@keystar/ui/style'
22
- import {
23
- type SortDescriptor,
24
- Cell,
25
- Column,
26
- Row,
27
- TableBody,
28
- TableHeader,
29
- TableView,
30
- } from '@keystar/ui/table'
31
- import { toastQueue } from '@keystar/ui/toast'
32
- import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'
33
- import { Heading, Text } from '@keystar/ui/typography'
34
-
35
- import { TextLink } from '@keystar/ui/link'
36
- import { Notice } from '@keystar/ui/notice'
37
- import type { TypedDocumentNode } from '../../../../admin-ui/apollo'
38
- import { CombinedGraphQLErrors, gql, useMutation, useQuery } from '../../../../admin-ui/apollo'
39
- import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink'
40
- import { EmptyState } from '../../../../admin-ui/components/EmptyState'
41
- import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice'
42
- import { PageContainer } from '../../../../admin-ui/components/PageContainer'
43
- import { useList } from '../../../../admin-ui/context'
44
- import {
45
- deserializeItemToValue,
46
- getConditionalFilterFieldKeys,
47
- isActionAvailable,
48
- resolveActionMode,
49
- serializeItemForConditionalFilters,
50
- } from '../../../../admin-ui/utils'
51
- import { getActionArguments } from '../../../../admin-ui/utils/actionData'
52
- import { pick } from '../../../../admin-ui/utils/pick'
53
- import { useSearchFilter } from '../../../../fields/types/relationship/views/useFilter'
54
- import type { ActionMeta, FieldMeta, JSONValue, ListMeta } from '../../../../types'
55
- import { FilterAdd } from './FilterAdd'
56
- import { PaginationControls, snapValueToClosest } from './PaginationControls'
57
- import { Tag } from './Tag'
58
-
59
- type ListPageProps = { listKey: string }
60
- export type Filter = {
61
- field: string
62
- type: string
63
- value: JSONValue
64
- }
65
-
66
- function FilterTag({
67
- filter,
68
- field,
69
- onChange,
70
- onRemove,
71
- }: {
72
- filter: Filter
73
- field: FieldMeta
74
- onChange: (filter: Filter) => void
75
- onRemove: () => void
76
- }) {
77
- const Label = field.controller.filter!.Label
78
- const tagElement = (
79
- <Tag onRemove={onRemove}>
80
- <Text>
81
- <span>{field.label} </span>
82
- <Label
83
- label={field.controller.filter!.types[filter.type].label}
84
- type={filter.type}
85
- value={filter.value}
86
- />
87
- </Text>
88
- </Tag>
89
- )
90
-
91
- // TODO: Special "empty" types need to be documented somewhere. Filters that
92
- // have no editable value, basically `null` or `!null`. Which offers:
93
- // * better DX — we can avoid weird nullable types and UIs that don't make sense
94
- // * better UX — users don't have to jump through mental hoops, like "is not exactly" + submit empty field
95
- if (filter.type === 'empty' || filter.type === 'not_empty') return tagElement
96
-
97
- return (
98
- <DialogTrigger type="popover" mobileType="tray">
99
- {tagElement}
100
- {onDismiss => (
101
- <FilterEdit onChange={onChange} onDismiss={onDismiss} field={field} filter={filter} />
102
- )}
103
- </DialogTrigger>
104
- )
105
- }
106
-
107
- function FilterEdit({
108
- filter,
109
- field,
110
- onChange: onAdd,
111
- onDismiss,
112
- }: {
113
- filter: Filter
114
- field: FieldMeta
115
- onChange: (filter: Filter) => void
116
- onDismiss: () => void
117
- }) {
118
- const formId = useId()
119
- const [value, setValue] = useState(filter.value)
120
- const onSubmit = (event: FormEvent) => {
121
- if (event.target !== event.currentTarget) return
122
- event.preventDefault()
123
-
124
- onAdd({
125
- ...filter,
126
- value,
127
- })
128
- onDismiss()
129
- }
130
-
131
- const Filter = field.controller.filter!.Filter
132
- const filterTypeLabel = field.controller.filter?.types[filter.type].label
133
-
134
- return (
135
- <Dialog>
136
- <Heading>{field.label}</Heading>
137
- <Content>
138
- <form onSubmit={onSubmit} id={formId}>
139
- <Filter
140
- autoFocus
141
- context="edit"
142
- typeLabel={filterTypeLabel}
143
- onChange={setValue}
144
- type={filter.type}
145
- value={value}
146
- />
147
- </form>
148
- </Content>
149
- <ButtonGroup>
150
- <Button onPress={onDismiss}>Cancel</Button>
151
- <Button type="submit" prominence="high" form={formId}>
152
- Save
153
- </Button>
154
- </ButtonGroup>
155
- </Dialog>
156
- )
157
- }
158
-
159
- function getFilters(list: ListMeta, query: ParsedUrlQueryInput): Filter[] {
160
- const param_ = query.filter
161
- const params = Array.isArray(param_) ? param_ : typeof param_ === 'string' ? [param_] : []
162
-
163
- if (!params.length) {
164
- if (!list.initialFilter) return []
165
-
166
- const filters: Filter[] = []
167
- for (const [fieldKey, filter] of Object.entries(list.initialFilter)) {
168
- const { controller } = list.fields[fieldKey]
169
- for (const f of controller.filter?.parseGraphQL(filter as any as never) ?? []) {
170
- filters.push({
171
- field: fieldKey,
172
- ...f,
173
- })
174
- }
175
- }
176
-
177
- return filters
178
- }
179
-
180
- const filters: Filter[] = []
181
- for (const [fieldPath, field] of Object.entries(list.fields)) {
182
- if (!field.isFilterable) continue
183
- if (!field.controller.filter) continue
184
-
185
- for (const filterType in field.controller.filter.types) {
186
- const prefix = `${fieldPath}_${filterType}`
187
- for (const queryFilter of params) {
188
- if (!queryFilter.startsWith(prefix)) continue
189
-
190
- if (queryFilter === prefix) {
191
- filters.push({
192
- type: filterType,
193
- field: fieldPath,
194
- value: null,
195
- })
196
- continue
197
- }
198
-
199
- const queryValue = queryFilter.slice(prefix.length + 1)
200
- try {
201
- const value = JSON.parse(queryValue)
202
- filters.push({
203
- type: filterType,
204
- field: fieldPath,
205
- value,
206
- })
207
- } catch (e) {
208
- console.error('Error parsing filter', queryFilter)
209
- }
210
- }
211
- }
212
- }
213
-
214
- return filters
215
- }
216
-
217
- function getSort(list: ListMeta, query: ParsedUrlQueryInput): SortDescriptor | null {
218
- const param = typeof query.sortBy === 'string' ? query.sortBy : null
219
- if (param === '') return null
220
- if (!param) {
221
- if (!list.initialSort) return null
222
- return {
223
- column: list.initialSort.field,
224
- direction: list.initialSort.direction === 'ASC' ? 'ascending' : 'descending',
225
- }
226
- }
227
-
228
- const fieldKey = param.startsWith('-') ? param.slice(1) : param
229
- const direction = param.startsWith('-') ? 'descending' : 'ascending'
230
- const field = list.fields[fieldKey]
231
- if (!field) return null
232
- if (!field.isOrderable) return null
233
-
234
- return {
235
- column: fieldKey,
236
- direction,
237
- }
238
- }
239
-
240
- function getCurrentPage(_: ListMeta, query: ParsedUrlQuery) {
241
- const currentPage = Number(query.page)
242
- if (Number.isNaN(currentPage) || currentPage < 1) return 1
243
- return currentPage
244
- }
245
-
246
- function getPageSize(list: ListMeta, query: ParsedUrlQuery) {
247
- const pageSize = Number(query.pageSize)
248
- if (Number.isNaN(pageSize) || pageSize < 1) return list.pageSize
249
- return snapValueToClosest(pageSize)
250
- }
251
-
252
- function getColumns(list: ListMeta, query: ParsedUrlQueryInput): string[] {
253
- const param_ = query.column
254
- const params = Array.isArray(param_) ? param_ : typeof param_ === 'string' ? [param_] : []
255
- if (!params.length) return list.initialColumns
256
- return params
257
- }
258
-
259
- export const getListPage = (props: ListPageProps) => () => <ListPage {...props} />
260
-
261
- type Selection = Set<string | number> | 'all'
262
- function ListPage({ listKey }: ListPageProps) {
263
- const localStorageListKey = `nixxie.list.${listKey}.list.page.info`
264
-
265
- const list = useList(listKey)
266
- const { query, replace: routerReplace, isReady } = useRouter()
267
- const [loaded, setLoaded] = useState(false)
268
- const [sort, setSort] = useState<SortDescriptor | null>(() => getSort(list, {}))
269
- const [columns, setColumns] = useState<string[]>(list.initialColumns)
270
- const [filters, setFilters] = useState<Filter[]>(() => getFilters(list, {}))
271
- const [currentPage, setCurrentPage] = useState<number>(1)
272
- const [pageSize, setPageSize] = useState<number>(list.pageSize)
273
- const [searchString, setSearchString] = useState('')
274
- const [selectedItems, setSelectedItems] = useState<Selection>(() => new Set([]))
275
- const [activeAction, setActiveAction] = useState<Key | null>(null)
276
- const [actionResult, setActionResult] = useState<ActionErrorResult | null>(null)
277
- const dirty = useMemo(() => {
278
- const defaultFilters = getFilters(list, {})
279
- const defaultSort = getSort(list, {})
280
- return (
281
- !!searchString ||
282
- !isDeepEqual(filters, defaultFilters) ||
283
- !isDeepEqual(sort, defaultSort) ||
284
- !isDeepEqual(columns, list.initialColumns)
285
- )
286
- }, [searchString, filters, sort, columns, list.initialColumns])
287
-
288
- useEffect(() => {
289
- if (!isReady) return
290
- if (loaded) return
291
- let localStorageQuery
292
- try {
293
- localStorageQuery = JSON.parse(localStorage.getItem(localStorageListKey) ?? '{}')
294
- } catch {}
295
-
296
- setSort(getSort(list, { ...localStorageQuery, ...query }))
297
- setColumns(getColumns(list, { ...localStorageQuery, ...query }))
298
- setFilters(getFilters(list, { ...localStorageQuery, ...query }))
299
- setCurrentPage(getCurrentPage(list, { ...localStorageQuery, ...query }))
300
- setPageSize(getPageSize(list, { ...localStorageQuery, ...query }))
301
- setSearchString(typeof query.search === 'string' ? query.search : '')
302
- setLoaded(true)
303
- }, [list, isReady])
304
-
305
- useEffect(() => {
306
- if (!isReady) return
307
- if (!loaded) return // TODO: stop this race condition properly
308
- const updatedQuery: ParsedUrlQueryInput = {
309
- ...(columns.length ? { column: columns } : {}),
310
- ...(sort ? { sortBy: sort.direction === 'ascending' ? sort.column : `-${sort.column}` } : {}),
311
- ...(filters.length
312
- ? {
313
- filter: (function () {
314
- const result: string[] = []
315
- for (const filter of filters) {
316
- if (filter.type === 'not_empty' || filter.type === 'empty') {
317
- result.push(`${filter.field}_${filter.type}`)
318
- continue
319
- }
320
-
321
- result.push(`${filter.field}_${filter.type}_${JSON.stringify(filter.value)}`)
322
- }
323
- return result
324
- })(),
325
- }
326
- : {}),
327
- ...(currentPage > 1 ? { page: currentPage } : {}),
328
- ...(pageSize !== list.pageSize ? { pageSize } : {}),
329
- ...(searchString ? { search: searchString } : {}),
330
- }
331
-
332
- localStorage.setItem(localStorageListKey, JSON.stringify(updatedQuery))
333
- routerReplace({ query: updatedQuery })
334
- }, [columns, sort, filters, currentPage, pageSize, searchString, list, loaded])
335
-
336
- const allowCreate = !(list.hideCreate ?? true)
337
- const isConstrained = Boolean(filters.length || query.search)
338
- const readableFields = Object.values(list.fields).map(f => ({
339
- id: f.key,
340
- value: f.key,
341
- label: f.label,
342
- isDisabled: f.listView.fieldMode === 'read',
343
- }))
344
- const where = useMemo(
345
- () =>
346
- filters.map(filter => {
347
- return list.fields[filter.field].controller.filter!.graphql({
348
- type: filter.type,
349
- value: filter.value,
350
- })
351
- }),
352
- [list, filters]
353
- )
354
-
355
- const actionsAvailable = useMemo(
356
- () => list.actions.filter(x => isActionAvailable(x, x.listView)),
357
- [list.actions]
358
- )
359
- const actionConditionalFilterFieldKeys = useMemo(() => {
360
- const fieldKeys = new Set<string>()
361
- for (const action of actionsAvailable) {
362
- for (const fieldKey of getConditionalFilterFieldKeys(action.listView.actionMode)) {
363
- fieldKeys.add(fieldKey)
364
- }
365
- }
366
- return fieldKeys
367
- }, [actionsAvailable])
368
- const search = useSearchFilter(searchString, list, list.initialSearchFields)
369
- const { data, error, refetch, loading } = useQuery(
370
- useMemo((): TypedDocumentNode<{
371
- items: Record<string, unknown>[] | null
372
- count: number | null
373
- }> => {
374
- const fieldKeys = new Set(columns) // only the shown columns
375
-
376
- // and any fields needed by the action filters
377
- for (const fieldKey of actionConditionalFilterFieldKeys) {
378
- fieldKeys.add(fieldKey)
379
- }
380
- for (const action of actionsAvailable) {
381
- for (const arg of action.graphql.arguments) {
382
- if (!arg.source) continue
383
- fieldKeys.add(arg.source.itemField)
384
- }
385
- }
386
-
387
- const fieldsToQuery = [...fieldKeys]
388
- .filter(fieldKey => fieldKey !== 'id') // id is always included
389
- .map(fieldKey => list.fields[fieldKey]?.controller.graphqlSelection)
390
- .filter(Boolean)
391
- .join('\n')
392
-
393
- // TODO: less interpolation
394
- return gql`
395
- query (
396
- $where: ${list.graphql.names.whereInputName},
397
- $take: Int!,
398
- $skip: Int!,
399
- $orderBy: [${list.graphql.names.listOrderName}!]
400
- ) {
401
- items: ${list.graphql.names.listQueryName}(
402
- where: $where,
403
- take: $take,
404
- skip: $skip,
405
- orderBy: $orderBy
406
- ) {
407
- id
408
- ${fieldsToQuery}
409
- }
410
- count: ${list.graphql.names.listQueryCountName}(where: $where)
411
- }
412
- `
413
- }, [list, list.fields, columns, actionsAvailable, actionConditionalFilterFieldKeys]),
414
- {
415
- fetchPolicy: 'cache-and-network',
416
- errorPolicy: 'all',
417
- variables: {
418
- where: {
419
- ...(where.length ? { AND: where } : {}),
420
- ...(search.length ? { OR: search } : {}),
421
- },
422
- take: pageSize,
423
- skip: (currentPage - 1) * pageSize,
424
- orderBy: sort
425
- ? [
426
- {
427
- [sort.column]: sort.direction === 'ascending' ? 'asc' : 'desc',
428
- },
429
- ]
430
- : undefined,
431
- },
432
- }
433
- )
434
-
435
- useEffect(() => {
436
- if (typeof data?.count !== 'number') return
437
-
438
- const lastPage = Math.max(Math.ceil(data.count / pageSize), 1)
439
- if (currentPage > lastPage) {
440
- setCurrentPage(lastPage)
441
- }
442
- }, [data])
443
-
444
- const selectedItemIds = (
445
- selectedItems === 'all' ? (data?.items?.map(item => item.id) ?? []) : Array.from(selectedItems)
446
- ).map(String)
447
- const isEmpty = Boolean(data?.count === 0 && !isConstrained)
448
- const headers = columns
449
- .map(column => {
450
- const field = list.fields[column]
451
- if (!field) return
452
- return {
453
- id: field.key,
454
- label: field.label,
455
- allowsSorting: !isConstrained && !data?.items?.length ? false : field.isOrderable,
456
- }
457
- })
458
- .filter((x): x is NonNullable<typeof x> => Boolean(x))
459
-
460
- function onAddFilter(newFilter: Filter) {
461
- setFilters(prevFilters => [...prevFilters, newFilter])
462
- }
463
-
464
- function resetToDefaults() {
465
- const defaultFilters = getFilters(list, {})
466
- const defaultSort = getSort(list, {})
467
- setSearchString('')
468
- setColumns(list.initialColumns)
469
- setFilters(defaultFilters)
470
- setSort(defaultSort)
471
- }
472
-
473
- const actionsList = [
474
- ...actionsAvailable,
475
- ...(list.hideDelete
476
- ? []
477
- : [
478
- {
479
- key: 'delete',
480
- label: 'Delete',
481
- icon: 'trash2Icon',
482
- graphql: {
483
- arguments: [],
484
- names: {
485
- one: list.graphql.names.deleteMutationName,
486
- many: list.graphql.names.deleteManyMutationName,
487
- },
488
- },
489
- messages: {
490
- promptTitle: 'Delete {singular}?',
491
- promptTitleMany: 'Delete {count} {singular|plural}?',
492
- prompt: 'Are you sure you want to delete {singular}? This action cannot be undone.',
493
- promptMany:
494
- 'Are you sure you want to delete {count} {singular|plural}? This action cannot be undone.',
495
- promptConfirmLabel: 'Yes, delete',
496
- promptConfirmLabelMany: 'Yes, delete',
497
- success: 'Deleted {singular}.',
498
- successMany: 'Deleted {countSuccess} {singular|plural}.',
499
- fail: 'Unable to delete {singular}.',
500
- failMany: 'Unable to delete {countFail} {singular|plural}.',
501
- },
502
- itemView: null as any, // unusud
503
- listView: { actionMode: list.hideDelete ? 'hidden' : 'enabled' },
504
- } as const,
505
- ]),
506
- ]
507
- const selectionMode = actionsList.length > 0 ? 'multiple' : 'none'
508
- const selectedRows = useMemo(
509
- () => data?.items?.filter(item => selectedItemIds.includes(String(item.id))) ?? [],
510
- [data?.items, selectedItemIds]
511
- )
512
- const actionConditionalFilterFields = useMemo(
513
- () => pick(list.fields, actionConditionalFilterFieldKeys),
514
- [actionConditionalFilterFieldKeys, list.fields]
515
- )
516
- const serializedSelectedRows = useMemo(
517
- () =>
518
- selectedRows.map(row => {
519
- const value = deserializeItemToValue(actionConditionalFilterFields, row)
520
- return serializeItemForConditionalFilters(actionConditionalFilterFields, value)
521
- }),
522
- [actionConditionalFilterFields, selectedRows]
523
- )
524
- const { actions, disabledKeys: disabledActionKeys } = useMemo(() => {
525
- const disabledKeys: string[] = []
526
- const actions: ActionMeta[] = []
527
- for (const action of actionsList) {
528
- let actionMode
529
- for (const serializedValue of serializedSelectedRows) {
530
- const mode = resolveActionMode(action.listView.actionMode, serializedValue)
531
- if (mode === 'hidden') {
532
- actionMode = 'hidden'
533
- break
534
- }
535
- if (mode === 'disabled') actionMode = 'disabled'
536
- }
537
- if (actionMode === 'hidden') continue
538
- if (actionMode === 'disabled') disabledKeys.push(action.key)
539
- actions.push(action)
540
- }
541
- return { actions, disabledKeys }
542
- }, [actionsList, serializedSelectedRows])
543
-
544
- return (
545
- <PageContainer
546
- header={<ListPageHeader listKey={listKey} showCreate={allowCreate} />}
547
- title={list.label}
548
- >
549
- <VStack flex gap="large" paddingX="xlarge" paddingY="xlarge" minHeight={0} minWidth={0}>
550
- <HStack gap="regular" alignItems="center">
551
- <SearchField
552
- aria-label="Search"
553
- isDisabled={isEmpty}
554
- onClear={() => setSearchString('')}
555
- onChange={v => setSearchString(v)}
556
- placeholder="Search…"
557
- value={searchString}
558
- width="alias.singleLineWidth"
559
- flexGrow={{ mobile: 1, tablet: 0 }}
560
- />
561
- <FilterAdd listKey={listKey} onAdd={onAddFilter} isDisabled={isEmpty} />
562
- <MenuTrigger>
563
- <ActionButton isDisabled={isEmpty}>
564
- <Text>Columns</Text>
565
- <Icon src={chevronDownIcon} />
566
- </ActionButton>
567
- <Menu
568
- items={readableFields}
569
- disallowEmptySelection
570
- onSelectionChange={selection => {
571
- if (selection === 'all') {
572
- setColumns(readableFields.map(field => field.id))
573
- } else {
574
- setColumns(readableFields.filter(f => selection.has(f.id)).map(f => f.id))
575
- }
576
- }}
577
- selectionMode="multiple"
578
- selectedKeys={columns}
579
- >
580
- {item => <Item key={item.value}>{item.label}</Item>}
581
- </Menu>
582
- </MenuTrigger>
583
- {dirty ? (
584
- <TooltipTrigger>
585
- <ActionButton aria-label="reset" onPress={resetToDefaults} prominence="low">
586
- <Icon src={undo2Icon} />
587
- </ActionButton>
588
- <Tooltip>Reset to defaults</Tooltip>
589
- </TooltipTrigger>
590
- ) : null}
591
- {isReady && loading && (
592
- <ProgressCircle aria-label="Loading…" size="small" isIndeterminate />
593
- )}
594
- </HStack>
595
-
596
- {filters.length ? (
597
- <Flex gap="small" wrap>
598
- {filters.map((filter, i) => {
599
- const field = list.fields[filter.field]
600
- function onRemove() {
601
- setFilters(prevFilters => prevFilters.filter(f => f !== filter))
602
- }
603
- function onChange(updatedFilter: Filter) {
604
- setFilters(prevFilters => [...prevFilters.filter(f => f !== filter), updatedFilter])
605
- }
606
-
607
- return (
608
- <FilterTag
609
- key={i}
610
- field={field}
611
- filter={filter}
612
- onChange={onChange}
613
- onRemove={onRemove}
614
- />
615
- )
616
- })}
617
- </Flex>
618
- ) : null}
619
-
620
- <GraphQLErrorNotice errors={[error]} />
621
-
622
- <ActionBarContainer flex minHeight="scale.3000">
623
- <TableView
624
- aria-labelledby={LIST_PAGE_TITLE_ID}
625
- selectionMode={selectionMode}
626
- onSortChange={setSort}
627
- sortDescriptor={sort ?? undefined}
628
- density="spacious"
629
- overflowMode="truncate"
630
- onSelectionChange={setSelectedItems}
631
- selectedKeys={selectedItems}
632
- renderEmptyState={() =>
633
- loading ? (
634
- <ProgressCircle aria-label="Preparing items" isIndeterminate />
635
- ) : isConstrained ? (
636
- <EmptyState
637
- icon={searchXIcon}
638
- title="No results"
639
- message="No items found. Try adjusting your search or filters."
640
- />
641
- ) : (
642
- <EmptyState
643
- icon={textSelectIcon}
644
- title="Empty list"
645
- message="Add the first item to see it here."
646
- />
647
- )
648
- }
649
- flex
650
- UNSAFE_style={{
651
- opacity: loading && !!data ? 0.5 : undefined,
652
- }}
653
- >
654
- <TableHeader columns={headers}>
655
- {({ label, id, ...options }) => (
656
- <Column key={id} isRowHeader {...options}>
657
- {label}
658
- </Column>
659
- )}
660
- </TableHeader>
661
- <TableBody items={data?.items ?? []}>
662
- {row => {
663
- return (
664
- <Row href={`/${list.path}/${row?.id}`}>
665
- {key => {
666
- const field = list.fields[key]
667
- const value = row[key]
668
- const CellContent = field.views.Cell
669
- return (
670
- <Cell>
671
- {CellContent ? (
672
- <CellContent value={value} field={field.controller} item={row} />
673
- ) : (
674
- <Text>{value?.toString()}</Text>
675
- )}
676
- </Cell>
677
- )
678
- }}
679
- </Row>
680
- )
681
- }}
682
- </TableBody>
683
- </TableView>
684
-
685
- <ActionBar
686
- selectedItemCount={selectedItemIds.length}
687
- onClearSelection={() => setSelectedItems(new Set())}
688
- UNSAFE_className={css({
689
- // TODO: update in @keystar/ui package
690
- // make `tokenSchema.size.shadow.regular` token "0 1px 4px"
691
- 'div:has([data-focus-scope-start])': {
692
- backgroundColor: tokenSchema.color.background.canvas,
693
- border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.emphasis}`,
694
- borderRadius: tokenSchema.size.radius.regular,
695
- boxShadow: `0 1px 4px ${tokenSchema.color.shadow.regular}`,
696
- },
697
- })}
698
- disabledKeys={disabledActionKeys}
699
- onAction={setActiveAction}
700
- >
701
- {[
702
- ...(function* () {
703
- for (const action of actions) {
704
- const iconComponent = action.icon ? KeystarIcons[action.icon] : null
705
- yield (
706
- <Item key={action.key} textValue={action.label}>
707
- {iconComponent ? <Icon src={iconComponent} /> : null}
708
- <Text>{action.label}</Text>
709
- </Item>
710
- )
711
- }
712
- })(),
713
- ]}
714
- </ActionBar>
715
- </ActionBarContainer>
716
-
717
- {!!data?.count && (
718
- <PaginationControls
719
- singular={list.singular}
720
- plural={list.plural}
721
- currentPage={currentPage}
722
- pageSize={pageSize}
723
- total={data.count}
724
- onChangePage={(page: number) => setCurrentPage(page)}
725
- onChangePageSize={(pageSize: number) => setPageSize(pageSize)}
726
- defaultPageSize={list.pageSize}
727
- />
728
- )}
729
-
730
- <DialogContainer
731
- onDismiss={() => {
732
- setActiveAction(null)
733
- }}
734
- >
735
- {actions
736
- .filter(action => action.key === activeAction)
737
- .map(action => {
738
- return (
739
- <ActionItemsDialog
740
- itemIds={selectedItemIds}
741
- items={data?.items ?? []}
742
- action={action}
743
- list={list}
744
- onSuccess={remaining => {
745
- refetch()
746
- setSelectedItems(remaining)
747
- }}
748
- onErrors={setActionResult}
749
- />
750
- )
751
- })
752
- .pop()}
753
- </DialogContainer>
754
- <DialogContainer onDismiss={() => setActionResult(null)} isDismissable>
755
- {actionResult ? (
756
- <Dialog>
757
- <Heading>Error details for {actionResult.action.label} action</Heading>
758
- <Content>
759
- <VStack gap="large">
760
- {[
761
- ...(function* () {
762
- const { action, errors: actionErrors } = actionResult
763
- for (const [itemId, itemActionErrors] of Object.entries(actionErrors)) {
764
- const item = data?.items?.find(i => i.id === itemId) ?? null
765
- const itemLabel = (item?.[list.labelField] as string | null) ?? itemId
766
- const href = `/${list.path}/${itemId}`
767
-
768
- for (const error of itemActionErrors) {
769
- yield (
770
- <VStack key={itemId} gap="regular">
771
- <Notice tone="critical">
772
- <Content>
773
- <Text>
774
- You might try running the action again from{' '}
775
- <TextLink href={href}>
776
- the {list.singular.toLowerCase()}.
777
- </TextLink>
778
- </Text>
779
- <Box
780
- elementType="pre"
781
- backgroundColor="critical"
782
- borderRadius="regular"
783
- maxHeight="100%"
784
- overflow="auto"
785
- >
786
- <Text
787
- color="critical"
788
- UNSAFE_className={css({
789
- fontFamily: tokenSchema.typography.fontFamily.code,
790
- })}
791
- >
792
- {error.message}
793
- </Text>
794
- </Box>
795
- </Content>
796
- <div>
797
- <Heading>
798
- {replace(
799
- action.messages.fail,
800
- list,
801
- { ...action, itemLabel },
802
- false
803
- )}
804
- </Heading>
805
- </div>
806
- </Notice>
807
- </VStack>
808
- )
809
- }
810
- }
811
- })(),
812
- ]}
813
- </VStack>
814
- </Content>
815
- </Dialog>
816
- ) : null}
817
- </DialogContainer>
818
- </VStack>
819
- </PageContainer>
820
- )
821
- }
822
-
823
- const LIST_PAGE_TITLE_ID = 'nixxie-list-page-title'
824
-
825
- function ListPageHeader({ listKey, showCreate }: { listKey: string; showCreate?: boolean }) {
826
- const list = useList(listKey)
827
- return (
828
- <Fragment>
829
- <Heading id={LIST_PAGE_TITLE_ID} elementType="h1" size="small">
830
- {list.label}
831
- </Heading>
832
- {showCreate && (
833
- <CreateButtonLink
834
- list={list}
835
- >{`New ${list.singular.toLocaleLowerCase()}`}</CreateButtonLink>
836
- )}
837
- </Fragment>
838
- )
839
- }
840
-
841
- function replace(
842
- s: string,
843
- list: ListMeta,
844
- args: ActionMeta & {
845
- itemLabel?: string
846
- count?: number
847
- countFail?: number
848
- countSuccess?: number
849
- },
850
- many: boolean
851
- ) {
852
- s = s.replaceAll('{Label}', args.label)
853
- s = s.replaceAll('{label}', args.label.toLowerCase())
854
- if (s.includes('{singular|plural}'))
855
- s = s.replaceAll('{singular|plural}', many ? '{plural}' : '{singular}')
856
- if (s.includes('{Singular}')) s = s.replaceAll('{Singular}', list.singular)
857
- if (s.includes('{Plural}')) s = s.replaceAll('{Plural}', list.plural)
858
- if (s.includes('{singular}')) s = s.replaceAll('{singular}', list.singular.toLowerCase())
859
- if (s.includes('{plural}')) s = s.replaceAll('{plural}', list.plural.toLowerCase())
860
- if ('count' in args) s = s.replaceAll('{count}', String(args.count))
861
- if ('countFail' in args) s = s.replaceAll('{countFail}', String(args.countFail))
862
- if ('countSuccess' in args) s = s.replaceAll('{countSuccess}', String(args.countSuccess))
863
- if ('itemLabel' in args) s = s.replaceAll('{itemLabel}', args.itemLabel ?? '')
864
- return s
865
- }
866
-
867
- type ActionErrors = Record<string, GraphQLFormattedError[]>
868
- type ActionErrorResult = {
869
- action: ActionMeta
870
- errors: ActionErrors
871
- }
872
-
873
- function ActionItemsDialog({
874
- list,
875
- itemIds,
876
- items,
877
- onSuccess,
878
- onErrors,
879
- action,
880
- }: {
881
- list: ListMeta
882
- itemIds: string[]
883
- items: Record<string, unknown>[]
884
- onSuccess: (remaining: Set<string>) => void
885
- onErrors: (result: ActionErrorResult) => void
886
- action: ActionMeta
887
- }) {
888
- const actionMutation =
889
- action.key === 'delete'
890
- ? gql`mutation($where: [${list.graphql.names.whereUniqueInputName}!]!) {
891
- results: ${action.graphql.names.many}(where: $where) {
892
- id
893
- }
894
- }`
895
- : gql`mutation($data: [${action.graphql.names.one[0].toUpperCase()}${action.graphql.names.one.slice(1)}Args!]!) {
896
- results: ${action.graphql.names.many}(data: $data) {
897
- id
898
- }
899
- }`
900
- const [actionOnItems] = useMutation<{ results?: ({ id: string } | null)[] }>(actionMutation, {
901
- variables:
902
- action.key === 'delete'
903
- ? { where: itemIds.map(id => ({ id })) }
904
- : {
905
- data: itemIds.flatMap(id => {
906
- const row = items.find(item => String(item.id) === id)
907
- if (!row) {
908
- return []
909
- }
910
- const deserialized = deserializeItemToValue(list.fields, row)
911
- const args = getActionArguments(list, action, deserialized)
912
- return { where: { id }, ...args }
913
- }),
914
- },
915
- errorPolicy: 'all',
916
- })
917
- const { messages: m } = action
918
-
919
- async function onTryAction() {
920
- try {
921
- const { error } = await actionOnItems()
922
- const failed = new Set<string>()
923
- const actionErrors: ActionErrors = {}
924
- let countFail = 0
925
- if (CombinedGraphQLErrors.is(error)) {
926
- countFail = error.errors.length
927
- for (const err of error.errors ?? []) {
928
- const i = err.path?.[1]
929
- if (typeof i !== 'number') continue
930
- const itemId = itemIds[i]
931
-
932
- failed.add(itemId)
933
- actionErrors[itemId] ??= []
934
- actionErrors[itemId].push(err)
935
- }
936
- }
937
- const countSuccess = itemIds.length - countFail
938
-
939
- if (countSuccess) {
940
- toastQueue.neutral(
941
- replace(
942
- m.successMany,
943
- list,
944
- {
945
- ...action,
946
- count: itemIds.length,
947
- countFail,
948
- countSuccess,
949
- },
950
- countSuccess > 1
951
- ),
952
- { timeout: 5000 }
953
- )
954
- }
955
-
956
- if (countFail) {
957
- toastQueue.critical(
958
- replace(
959
- m.failMany,
960
- list,
961
- {
962
- ...action,
963
- count: itemIds.length,
964
- countFail,
965
- countSuccess,
966
- },
967
- countFail > 1
968
- ),
969
- {
970
- actionLabel: 'Details',
971
- onAction: () => onErrors({ action, errors: actionErrors }),
972
- shouldCloseOnAction: true,
973
- }
974
- )
975
- }
976
-
977
- return onSuccess(failed)
978
- } catch (error) {
979
- console.error(error)
980
- }
981
- }
982
-
983
- return (
984
- <AlertDialog
985
- tone={action.key === 'delete' ? 'critical' : 'neutral'}
986
- title={replace(
987
- m.promptTitleMany,
988
- list,
989
- { ...action, count: itemIds.length },
990
- itemIds.length > 1
991
- )}
992
- cancelLabel="Cancel"
993
- primaryActionLabel={replace(
994
- m.promptConfirmLabelMany,
995
- list,
996
- { ...action, count: itemIds.length },
997
- itemIds.length > 1
998
- )}
999
- onPrimaryAction={onTryAction}
1000
- >
1001
- <Text>
1002
- {replace(m.promptMany, list, { ...action, count: itemIds.length }, itemIds.length > 1)}
1003
- </Text>
1004
- </AlertDialog>
1005
- )
1006
- }
1
+ import isDeepEqual from 'fast-deep-equal'
2
+ import type { GraphQLFormattedError } from 'graphql'
3
+ import { useRouter } from 'next/router'
4
+ import type { ParsedUrlQuery, ParsedUrlQueryInput } from 'querystring'
5
+ import { type FormEvent, type Key, Fragment, useEffect, useId, useMemo, useState } from 'react'
6
+
7
+ import { ActionBar, ActionBarContainer, Item } from '@keystar/ui/action-bar'
8
+ import { ActionButton, Button, ButtonGroup } from '@keystar/ui/button'
9
+ import { AlertDialog, Dialog, DialogContainer, DialogTrigger } from '@keystar/ui/dialog'
10
+ import { Icon } from '@keystar/ui/icon'
11
+ import { allIcons as KeystarIcons } from '@keystar/ui/icon/all'
12
+ import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'
13
+ import { searchXIcon } from '@keystar/ui/icon/icons/searchXIcon'
14
+ import { textSelectIcon } from '@keystar/ui/icon/icons/textSelectIcon'
15
+ import { undo2Icon } from '@keystar/ui/icon/icons/undo2Icon'
16
+ import { Box, Flex, HStack, VStack } from '@keystar/ui/layout'
17
+ import { Menu, MenuTrigger } from '@keystar/ui/menu'
18
+ import { ProgressCircle } from '@keystar/ui/progress'
19
+ import { SearchField } from '@keystar/ui/search-field'
20
+ import { Content } from '@keystar/ui/slots'
21
+ import { css, tokenSchema } from '@keystar/ui/style'
22
+ import {
23
+ type SortDescriptor,
24
+ Cell,
25
+ Column,
26
+ Row,
27
+ TableBody,
28
+ TableHeader,
29
+ TableView,
30
+ } from '@keystar/ui/table'
31
+ import { toastQueue } from '@keystar/ui/toast'
32
+ import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'
33
+ import { Heading, Text } from '@keystar/ui/typography'
34
+
35
+ import { TextLink } from '@keystar/ui/link'
36
+ import { Notice } from '@keystar/ui/notice'
37
+ import type { TypedDocumentNode } from '../../../../admin-ui/apollo'
38
+ import { CombinedGraphQLErrors, gql, useMutation, useQuery } from '../../../../admin-ui/apollo'
39
+ import { CreateButtonLink } from '../../../../admin-ui/components/CreateButtonLink'
40
+ import { EmptyState } from '../../../../admin-ui/components/EmptyState'
41
+ import { GraphQLErrorNotice } from '../../../../admin-ui/components/GraphQLErrorNotice'
42
+ import { PageContainer } from '../../../../admin-ui/components/PageContainer'
43
+ import { useList } from '../../../../admin-ui/context'
44
+ import {
45
+ deserializeItemToValue,
46
+ getConditionalFilterFieldKeys,
47
+ isActionAvailable,
48
+ resolveActionMode,
49
+ serializeItemForConditionalFilters,
50
+ } from '../../../../admin-ui/utils'
51
+ import { getActionArguments } from '../../../../admin-ui/utils/actionData'
52
+ import { pick } from '../../../../admin-ui/utils/pick'
53
+ import { useSearchFilter } from '../../../../fields/types/relationship/views/useFilter'
54
+ import type { ActionMeta, FieldMeta, JSONValue, ListMeta } from '../../../../types'
55
+ import { FilterAdd } from './FilterAdd'
56
+ import { PaginationControls, snapValueToClosest } from './PaginationControls'
57
+ import { Tag } from './Tag'
58
+
59
+ type ListPageProps = { listKey: string }
60
+ export type Filter = {
61
+ field: string
62
+ type: string
63
+ value: JSONValue
64
+ }
65
+
66
+ function FilterTag({
67
+ filter,
68
+ field,
69
+ onChange,
70
+ onRemove,
71
+ }: {
72
+ filter: Filter
73
+ field: FieldMeta
74
+ onChange: (filter: Filter) => void
75
+ onRemove: () => void
76
+ }) {
77
+ const Label = field.controller.filter!.Label
78
+ const tagElement = (
79
+ <Tag onRemove={onRemove}>
80
+ <Text>
81
+ <span>{field.label} </span>
82
+ <Label
83
+ label={field.controller.filter!.types[filter.type].label}
84
+ type={filter.type}
85
+ value={filter.value}
86
+ />
87
+ </Text>
88
+ </Tag>
89
+ )
90
+
91
+ // TODO: Special "empty" types need to be documented somewhere. Filters that
92
+ // have no editable value, basically `null` or `!null`. Which offers:
93
+ // * better DX — we can avoid weird nullable types and UIs that don't make sense
94
+ // * better UX — users don't have to jump through mental hoops, like "is not exactly" + submit empty field
95
+ if (filter.type === 'empty' || filter.type === 'not_empty') return tagElement
96
+
97
+ return (
98
+ <DialogTrigger type="popover" mobileType="tray">
99
+ {tagElement}
100
+ {onDismiss => (
101
+ <FilterEdit onChange={onChange} onDismiss={onDismiss} field={field} filter={filter} />
102
+ )}
103
+ </DialogTrigger>
104
+ )
105
+ }
106
+
107
+ function FilterEdit({
108
+ filter,
109
+ field,
110
+ onChange: onAdd,
111
+ onDismiss,
112
+ }: {
113
+ filter: Filter
114
+ field: FieldMeta
115
+ onChange: (filter: Filter) => void
116
+ onDismiss: () => void
117
+ }) {
118
+ const formId = useId()
119
+ const [value, setValue] = useState(filter.value)
120
+ const onSubmit = (event: FormEvent) => {
121
+ if (event.target !== event.currentTarget) return
122
+ event.preventDefault()
123
+
124
+ onAdd({
125
+ ...filter,
126
+ value,
127
+ })
128
+ onDismiss()
129
+ }
130
+
131
+ const Filter = field.controller.filter!.Filter
132
+ const filterTypeLabel = field.controller.filter?.types[filter.type].label
133
+
134
+ return (
135
+ <Dialog>
136
+ <Heading>{field.label}</Heading>
137
+ <Content>
138
+ <form onSubmit={onSubmit} id={formId}>
139
+ <Filter
140
+ autoFocus
141
+ context="edit"
142
+ typeLabel={filterTypeLabel}
143
+ onChange={setValue}
144
+ type={filter.type}
145
+ value={value}
146
+ />
147
+ </form>
148
+ </Content>
149
+ <ButtonGroup>
150
+ <Button onPress={onDismiss}>Cancel</Button>
151
+ <Button type="submit" prominence="high" form={formId}>
152
+ Save
153
+ </Button>
154
+ </ButtonGroup>
155
+ </Dialog>
156
+ )
157
+ }
158
+
159
+ function getFilters(list: ListMeta, query: ParsedUrlQueryInput): Filter[] {
160
+ const param_ = query.filter
161
+ const params = Array.isArray(param_) ? param_ : typeof param_ === 'string' ? [param_] : []
162
+
163
+ if (!params.length) {
164
+ if (!list.initialFilter) return []
165
+
166
+ const filters: Filter[] = []
167
+ for (const [fieldKey, filter] of Object.entries(list.initialFilter)) {
168
+ const { controller } = list.fields[fieldKey]
169
+ for (const f of controller.filter?.parseGraphQL(filter as any as never) ?? []) {
170
+ filters.push({
171
+ field: fieldKey,
172
+ ...f,
173
+ })
174
+ }
175
+ }
176
+
177
+ return filters
178
+ }
179
+
180
+ const filters: Filter[] = []
181
+ for (const [fieldPath, field] of Object.entries(list.fields)) {
182
+ if (!field.isFilterable) continue
183
+ if (!field.controller.filter) continue
184
+
185
+ for (const filterType in field.controller.filter.types) {
186
+ const prefix = `${fieldPath}_${filterType}`
187
+ for (const queryFilter of params) {
188
+ if (!queryFilter.startsWith(prefix)) continue
189
+
190
+ if (queryFilter === prefix) {
191
+ filters.push({
192
+ type: filterType,
193
+ field: fieldPath,
194
+ value: null,
195
+ })
196
+ continue
197
+ }
198
+
199
+ const queryValue = queryFilter.slice(prefix.length + 1)
200
+ try {
201
+ const value = JSON.parse(queryValue)
202
+ filters.push({
203
+ type: filterType,
204
+ field: fieldPath,
205
+ value,
206
+ })
207
+ } catch (e) {
208
+ console.error('Error parsing filter', queryFilter)
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ return filters
215
+ }
216
+
217
+ function getSort(list: ListMeta, query: ParsedUrlQueryInput): SortDescriptor | null {
218
+ const param = typeof query.sortBy === 'string' ? query.sortBy : null
219
+ if (param === '') return null
220
+ if (!param) {
221
+ if (!list.initialSort) return null
222
+ return {
223
+ column: list.initialSort.field,
224
+ direction: list.initialSort.direction === 'ASC' ? 'ascending' : 'descending',
225
+ }
226
+ }
227
+
228
+ const fieldKey = param.startsWith('-') ? param.slice(1) : param
229
+ const direction = param.startsWith('-') ? 'descending' : 'ascending'
230
+ const field = list.fields[fieldKey]
231
+ if (!field) return null
232
+ if (!field.isOrderable) return null
233
+
234
+ return {
235
+ column: fieldKey,
236
+ direction,
237
+ }
238
+ }
239
+
240
+ function getCurrentPage(_: ListMeta, query: ParsedUrlQuery) {
241
+ const currentPage = Number(query.page)
242
+ if (Number.isNaN(currentPage) || currentPage < 1) return 1
243
+ return currentPage
244
+ }
245
+
246
+ function getPageSize(list: ListMeta, query: ParsedUrlQuery) {
247
+ const pageSize = Number(query.pageSize)
248
+ if (Number.isNaN(pageSize) || pageSize < 1) return list.pageSize
249
+ return snapValueToClosest(pageSize)
250
+ }
251
+
252
+ function getColumns(list: ListMeta, query: ParsedUrlQueryInput): string[] {
253
+ const param_ = query.column
254
+ const params = Array.isArray(param_) ? param_ : typeof param_ === 'string' ? [param_] : []
255
+ if (!params.length) return list.initialColumns
256
+ return params
257
+ }
258
+
259
+ export const getListPage = (props: ListPageProps) => () => <ListPage {...props} />
260
+
261
+ type Selection = Set<string | number> | 'all'
262
+ function ListPage({ listKey }: ListPageProps) {
263
+ const localStorageListKey = `nixxie.list.${listKey}.list.page.info`
264
+
265
+ const list = useList(listKey)
266
+ const { query, replace: routerReplace, isReady } = useRouter()
267
+ const [loaded, setLoaded] = useState(false)
268
+ const [sort, setSort] = useState<SortDescriptor | null>(() => getSort(list, {}))
269
+ const [columns, setColumns] = useState<string[]>(list.initialColumns)
270
+ const [filters, setFilters] = useState<Filter[]>(() => getFilters(list, {}))
271
+ const [currentPage, setCurrentPage] = useState<number>(1)
272
+ const [pageSize, setPageSize] = useState<number>(list.pageSize)
273
+ const [searchString, setSearchString] = useState('')
274
+ const [selectedItems, setSelectedItems] = useState<Selection>(() => new Set([]))
275
+ const [activeAction, setActiveAction] = useState<Key | null>(null)
276
+ const [actionResult, setActionResult] = useState<ActionErrorResult | null>(null)
277
+ const dirty = useMemo(() => {
278
+ const defaultFilters = getFilters(list, {})
279
+ const defaultSort = getSort(list, {})
280
+ return (
281
+ !!searchString ||
282
+ !isDeepEqual(filters, defaultFilters) ||
283
+ !isDeepEqual(sort, defaultSort) ||
284
+ !isDeepEqual(columns, list.initialColumns)
285
+ )
286
+ }, [searchString, filters, sort, columns, list.initialColumns])
287
+
288
+ useEffect(() => {
289
+ if (!isReady) return
290
+ if (loaded) return
291
+ let localStorageQuery
292
+ try {
293
+ localStorageQuery = JSON.parse(localStorage.getItem(localStorageListKey) ?? '{}')
294
+ } catch {}
295
+
296
+ setSort(getSort(list, { ...localStorageQuery, ...query }))
297
+ setColumns(getColumns(list, { ...localStorageQuery, ...query }))
298
+ setFilters(getFilters(list, { ...localStorageQuery, ...query }))
299
+ setCurrentPage(getCurrentPage(list, { ...localStorageQuery, ...query }))
300
+ setPageSize(getPageSize(list, { ...localStorageQuery, ...query }))
301
+ setSearchString(typeof query.search === 'string' ? query.search : '')
302
+ setLoaded(true)
303
+ }, [list, isReady])
304
+
305
+ useEffect(() => {
306
+ if (!isReady) return
307
+ if (!loaded) return // TODO: stop this race condition properly
308
+ const updatedQuery: ParsedUrlQueryInput = {
309
+ ...(columns.length ? { column: columns } : {}),
310
+ ...(sort ? { sortBy: sort.direction === 'ascending' ? sort.column : `-${sort.column}` } : {}),
311
+ ...(filters.length
312
+ ? {
313
+ filter: (function () {
314
+ const result: string[] = []
315
+ for (const filter of filters) {
316
+ if (filter.type === 'not_empty' || filter.type === 'empty') {
317
+ result.push(`${filter.field}_${filter.type}`)
318
+ continue
319
+ }
320
+
321
+ result.push(`${filter.field}_${filter.type}_${JSON.stringify(filter.value)}`)
322
+ }
323
+ return result
324
+ })(),
325
+ }
326
+ : {}),
327
+ ...(currentPage > 1 ? { page: currentPage } : {}),
328
+ ...(pageSize !== list.pageSize ? { pageSize } : {}),
329
+ ...(searchString ? { search: searchString } : {}),
330
+ }
331
+
332
+ localStorage.setItem(localStorageListKey, JSON.stringify(updatedQuery))
333
+ routerReplace({ query: updatedQuery })
334
+ }, [columns, sort, filters, currentPage, pageSize, searchString, list, loaded])
335
+
336
+ const allowCreate = !(list.hideCreate ?? true)
337
+ const isConstrained = Boolean(filters.length || query.search)
338
+ const readableFields = Object.values(list.fields).map(f => ({
339
+ id: f.key,
340
+ value: f.key,
341
+ label: f.label,
342
+ isDisabled: f.listView.fieldMode === 'read',
343
+ }))
344
+ const where = useMemo(
345
+ () =>
346
+ filters.map(filter => {
347
+ return list.fields[filter.field].controller.filter!.graphql({
348
+ type: filter.type,
349
+ value: filter.value,
350
+ })
351
+ }),
352
+ [list, filters]
353
+ )
354
+
355
+ const actionsAvailable = useMemo(
356
+ () => list.actions.filter(x => isActionAvailable(x, x.listView)),
357
+ [list.actions]
358
+ )
359
+ const actionConditionalFilterFieldKeys = useMemo(() => {
360
+ const fieldKeys = new Set<string>()
361
+ for (const action of actionsAvailable) {
362
+ for (const fieldKey of getConditionalFilterFieldKeys(action.listView.actionMode)) {
363
+ fieldKeys.add(fieldKey)
364
+ }
365
+ }
366
+ return fieldKeys
367
+ }, [actionsAvailable])
368
+ const search = useSearchFilter(searchString, list, list.initialSearchFields)
369
+ const { data, error, refetch, loading } = useQuery(
370
+ useMemo((): TypedDocumentNode<{
371
+ items: Record<string, unknown>[] | null
372
+ count: number | null
373
+ }> => {
374
+ const fieldKeys = new Set(columns) // only the shown columns
375
+
376
+ // and any fields needed by the action filters
377
+ for (const fieldKey of actionConditionalFilterFieldKeys) {
378
+ fieldKeys.add(fieldKey)
379
+ }
380
+ for (const action of actionsAvailable) {
381
+ for (const arg of action.graphql.arguments) {
382
+ if (!arg.source) continue
383
+ fieldKeys.add(arg.source.itemField)
384
+ }
385
+ }
386
+
387
+ const fieldsToQuery = [...fieldKeys]
388
+ .filter(fieldKey => fieldKey !== 'id') // id is always included
389
+ .map(fieldKey => list.fields[fieldKey]?.controller.graphqlSelection)
390
+ .filter(Boolean)
391
+ .join('\n')
392
+
393
+ // TODO: less interpolation
394
+ return gql`
395
+ query (
396
+ $where: ${list.graphql.names.whereInputName},
397
+ $take: Int!,
398
+ $skip: Int!,
399
+ $orderBy: [${list.graphql.names.listOrderName}!]
400
+ ) {
401
+ items: ${list.graphql.names.listQueryName}(
402
+ where: $where,
403
+ take: $take,
404
+ skip: $skip,
405
+ orderBy: $orderBy
406
+ ) {
407
+ id
408
+ ${fieldsToQuery}
409
+ }
410
+ count: ${list.graphql.names.listQueryCountName}(where: $where)
411
+ }
412
+ `
413
+ }, [list, list.fields, columns, actionsAvailable, actionConditionalFilterFieldKeys]),
414
+ {
415
+ fetchPolicy: 'cache-and-network',
416
+ errorPolicy: 'all',
417
+ variables: {
418
+ where: {
419
+ ...(where.length ? { AND: where } : {}),
420
+ ...(search.length ? { OR: search } : {}),
421
+ },
422
+ take: pageSize,
423
+ skip: (currentPage - 1) * pageSize,
424
+ orderBy: sort
425
+ ? [
426
+ {
427
+ [sort.column]: sort.direction === 'ascending' ? 'asc' : 'desc',
428
+ },
429
+ ]
430
+ : undefined,
431
+ },
432
+ }
433
+ )
434
+
435
+ useEffect(() => {
436
+ if (typeof data?.count !== 'number') return
437
+
438
+ const lastPage = Math.max(Math.ceil(data.count / pageSize), 1)
439
+ if (currentPage > lastPage) {
440
+ setCurrentPage(lastPage)
441
+ }
442
+ }, [data])
443
+
444
+ const selectedItemIds = (
445
+ selectedItems === 'all' ? (data?.items?.map(item => item.id) ?? []) : Array.from(selectedItems)
446
+ ).map(String)
447
+ const isEmpty = Boolean(data?.count === 0 && !isConstrained)
448
+ const headers = columns
449
+ .map(column => {
450
+ const field = list.fields[column]
451
+ if (!field) return
452
+ return {
453
+ id: field.key,
454
+ label: field.label,
455
+ allowsSorting: !isConstrained && !data?.items?.length ? false : field.isOrderable,
456
+ }
457
+ })
458
+ .filter((x): x is NonNullable<typeof x> => Boolean(x))
459
+
460
+ function onAddFilter(newFilter: Filter) {
461
+ setFilters(prevFilters => [...prevFilters, newFilter])
462
+ }
463
+
464
+ function resetToDefaults() {
465
+ const defaultFilters = getFilters(list, {})
466
+ const defaultSort = getSort(list, {})
467
+ setSearchString('')
468
+ setColumns(list.initialColumns)
469
+ setFilters(defaultFilters)
470
+ setSort(defaultSort)
471
+ }
472
+
473
+ const actionsList = [
474
+ ...actionsAvailable,
475
+ ...(list.hideDelete
476
+ ? []
477
+ : [
478
+ {
479
+ key: 'delete',
480
+ label: 'Delete',
481
+ icon: 'trash2Icon',
482
+ graphql: {
483
+ arguments: [],
484
+ names: {
485
+ one: list.graphql.names.deleteMutationName,
486
+ many: list.graphql.names.deleteManyMutationName,
487
+ },
488
+ },
489
+ messages: {
490
+ promptTitle: 'Delete {singular}?',
491
+ promptTitleMany: 'Delete {count} {singular|plural}?',
492
+ prompt: 'Are you sure you want to delete {singular}? This action cannot be undone.',
493
+ promptMany:
494
+ 'Are you sure you want to delete {count} {singular|plural}? This action cannot be undone.',
495
+ promptConfirmLabel: 'Yes, delete',
496
+ promptConfirmLabelMany: 'Yes, delete',
497
+ success: 'Deleted {singular}.',
498
+ successMany: 'Deleted {countSuccess} {singular|plural}.',
499
+ fail: 'Unable to delete {singular}.',
500
+ failMany: 'Unable to delete {countFail} {singular|plural}.',
501
+ },
502
+ itemView: null as any, // unusud
503
+ listView: { actionMode: list.hideDelete ? 'hidden' : 'enabled' },
504
+ } as const,
505
+ ]),
506
+ ]
507
+ const selectionMode = actionsList.length > 0 ? 'multiple' : 'none'
508
+ const selectedRows = useMemo(
509
+ () => data?.items?.filter(item => selectedItemIds.includes(String(item.id))) ?? [],
510
+ [data?.items, selectedItemIds]
511
+ )
512
+ const actionConditionalFilterFields = useMemo(
513
+ () => pick(list.fields, actionConditionalFilterFieldKeys),
514
+ [actionConditionalFilterFieldKeys, list.fields]
515
+ )
516
+ const serializedSelectedRows = useMemo(
517
+ () =>
518
+ selectedRows.map(row => {
519
+ const value = deserializeItemToValue(actionConditionalFilterFields, row)
520
+ return serializeItemForConditionalFilters(actionConditionalFilterFields, value)
521
+ }),
522
+ [actionConditionalFilterFields, selectedRows]
523
+ )
524
+ const { actions, disabledKeys: disabledActionKeys } = useMemo(() => {
525
+ const disabledKeys: string[] = []
526
+ const actions: ActionMeta[] = []
527
+ for (const action of actionsList) {
528
+ let actionMode
529
+ for (const serializedValue of serializedSelectedRows) {
530
+ const mode = resolveActionMode(action.listView.actionMode, serializedValue)
531
+ if (mode === 'hidden') {
532
+ actionMode = 'hidden'
533
+ break
534
+ }
535
+ if (mode === 'disabled') actionMode = 'disabled'
536
+ }
537
+ if (actionMode === 'hidden') continue
538
+ if (actionMode === 'disabled') disabledKeys.push(action.key)
539
+ actions.push(action)
540
+ }
541
+ return { actions, disabledKeys }
542
+ }, [actionsList, serializedSelectedRows])
543
+
544
+ return (
545
+ <PageContainer
546
+ header={<ListPageHeader listKey={listKey} showCreate={allowCreate} />}
547
+ title={list.label}
548
+ >
549
+ <VStack flex gap="large" paddingX="xlarge" paddingY="xlarge" minHeight={0} minWidth={0}>
550
+ <HStack gap="regular" alignItems="center">
551
+ <SearchField
552
+ aria-label="Search"
553
+ isDisabled={isEmpty}
554
+ onClear={() => setSearchString('')}
555
+ onChange={v => setSearchString(v)}
556
+ placeholder="Search…"
557
+ value={searchString}
558
+ width="alias.singleLineWidth"
559
+ flexGrow={{ mobile: 1, tablet: 0 }}
560
+ />
561
+ <FilterAdd listKey={listKey} onAdd={onAddFilter} isDisabled={isEmpty} />
562
+ <MenuTrigger>
563
+ <ActionButton isDisabled={isEmpty}>
564
+ <Text>Columns</Text>
565
+ <Icon src={chevronDownIcon} />
566
+ </ActionButton>
567
+ <Menu
568
+ items={readableFields}
569
+ disallowEmptySelection
570
+ onSelectionChange={selection => {
571
+ if (selection === 'all') {
572
+ setColumns(readableFields.map(field => field.id))
573
+ } else {
574
+ setColumns(readableFields.filter(f => selection.has(f.id)).map(f => f.id))
575
+ }
576
+ }}
577
+ selectionMode="multiple"
578
+ selectedKeys={columns}
579
+ >
580
+ {item => <Item key={item.value}>{item.label}</Item>}
581
+ </Menu>
582
+ </MenuTrigger>
583
+ {dirty ? (
584
+ <TooltipTrigger>
585
+ <ActionButton aria-label="reset" onPress={resetToDefaults} prominence="low">
586
+ <Icon src={undo2Icon} />
587
+ </ActionButton>
588
+ <Tooltip>Reset to defaults</Tooltip>
589
+ </TooltipTrigger>
590
+ ) : null}
591
+ {isReady && loading && (
592
+ <ProgressCircle aria-label="Loading…" size="small" isIndeterminate />
593
+ )}
594
+ </HStack>
595
+
596
+ {filters.length ? (
597
+ <Flex gap="small" wrap>
598
+ {filters.map((filter, i) => {
599
+ const field = list.fields[filter.field]
600
+ function onRemove() {
601
+ setFilters(prevFilters => prevFilters.filter(f => f !== filter))
602
+ }
603
+ function onChange(updatedFilter: Filter) {
604
+ setFilters(prevFilters => [...prevFilters.filter(f => f !== filter), updatedFilter])
605
+ }
606
+
607
+ return (
608
+ <FilterTag
609
+ key={i}
610
+ field={field}
611
+ filter={filter}
612
+ onChange={onChange}
613
+ onRemove={onRemove}
614
+ />
615
+ )
616
+ })}
617
+ </Flex>
618
+ ) : null}
619
+
620
+ <GraphQLErrorNotice errors={[error]} />
621
+
622
+ <ActionBarContainer flex minHeight="scale.3000">
623
+ <TableView
624
+ aria-labelledby={LIST_PAGE_TITLE_ID}
625
+ selectionMode={selectionMode}
626
+ onSortChange={setSort}
627
+ sortDescriptor={sort ?? undefined}
628
+ density="spacious"
629
+ overflowMode="truncate"
630
+ onSelectionChange={setSelectedItems}
631
+ selectedKeys={selectedItems}
632
+ renderEmptyState={() =>
633
+ loading ? (
634
+ <ProgressCircle aria-label="Preparing items" isIndeterminate />
635
+ ) : isConstrained ? (
636
+ <EmptyState
637
+ icon={searchXIcon}
638
+ title="No results"
639
+ message="No items found. Try adjusting your search or filters."
640
+ />
641
+ ) : (
642
+ <EmptyState
643
+ icon={textSelectIcon}
644
+ title="Empty list"
645
+ message="Add the first item to see it here."
646
+ />
647
+ )
648
+ }
649
+ flex
650
+ UNSAFE_style={{
651
+ opacity: loading && !!data ? 0.5 : undefined,
652
+ }}
653
+ >
654
+ <TableHeader columns={headers}>
655
+ {({ label, id, ...options }) => (
656
+ <Column key={id} isRowHeader {...options}>
657
+ {label}
658
+ </Column>
659
+ )}
660
+ </TableHeader>
661
+ <TableBody items={data?.items ?? []}>
662
+ {row => {
663
+ return (
664
+ <Row href={`/${list.path}/${row?.id}`}>
665
+ {key => {
666
+ const field = list.fields[key]
667
+ const value = row[key]
668
+ const CellContent = field.views.Cell
669
+ return (
670
+ <Cell>
671
+ {CellContent ? (
672
+ <CellContent value={value} field={field.controller} item={row} />
673
+ ) : (
674
+ <Text>{value?.toString()}</Text>
675
+ )}
676
+ </Cell>
677
+ )
678
+ }}
679
+ </Row>
680
+ )
681
+ }}
682
+ </TableBody>
683
+ </TableView>
684
+
685
+ <ActionBar
686
+ selectedItemCount={selectedItemIds.length}
687
+ onClearSelection={() => setSelectedItems(new Set())}
688
+ UNSAFE_className={css({
689
+ // TODO: update in @keystar/ui package
690
+ // make `tokenSchema.size.shadow.regular` token "0 1px 4px"
691
+ 'div:has([data-focus-scope-start])': {
692
+ backgroundColor: tokenSchema.color.background.canvas,
693
+ border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.emphasis}`,
694
+ borderRadius: tokenSchema.size.radius.regular,
695
+ boxShadow: `0 1px 4px ${tokenSchema.color.shadow.regular}`,
696
+ },
697
+ })}
698
+ disabledKeys={disabledActionKeys}
699
+ onAction={setActiveAction}
700
+ >
701
+ {[
702
+ ...(function* () {
703
+ for (const action of actions) {
704
+ const iconComponent = action.icon ? KeystarIcons[action.icon] : null
705
+ yield (
706
+ <Item key={action.key} textValue={action.label}>
707
+ {iconComponent ? <Icon src={iconComponent} /> : null}
708
+ <Text>{action.label}</Text>
709
+ </Item>
710
+ )
711
+ }
712
+ })(),
713
+ ]}
714
+ </ActionBar>
715
+ </ActionBarContainer>
716
+
717
+ {!!data?.count && (
718
+ <PaginationControls
719
+ singular={list.singular}
720
+ plural={list.plural}
721
+ currentPage={currentPage}
722
+ pageSize={pageSize}
723
+ total={data.count}
724
+ onChangePage={(page: number) => setCurrentPage(page)}
725
+ onChangePageSize={(pageSize: number) => setPageSize(pageSize)}
726
+ defaultPageSize={list.pageSize}
727
+ />
728
+ )}
729
+
730
+ <DialogContainer
731
+ onDismiss={() => {
732
+ setActiveAction(null)
733
+ }}
734
+ >
735
+ {actions
736
+ .filter(action => action.key === activeAction)
737
+ .map(action => {
738
+ return (
739
+ <ActionItemsDialog
740
+ itemIds={selectedItemIds}
741
+ items={data?.items ?? []}
742
+ action={action}
743
+ list={list}
744
+ onSuccess={remaining => {
745
+ refetch()
746
+ setSelectedItems(remaining)
747
+ }}
748
+ onErrors={setActionResult}
749
+ />
750
+ )
751
+ })
752
+ .pop()}
753
+ </DialogContainer>
754
+ <DialogContainer onDismiss={() => setActionResult(null)} isDismissable>
755
+ {actionResult ? (
756
+ <Dialog>
757
+ <Heading>Error details for {actionResult.action.label} action</Heading>
758
+ <Content>
759
+ <VStack gap="large">
760
+ {[
761
+ ...(function* () {
762
+ const { action, errors: actionErrors } = actionResult
763
+ for (const [itemId, itemActionErrors] of Object.entries(actionErrors)) {
764
+ const item = data?.items?.find(i => i.id === itemId) ?? null
765
+ const itemLabel = (item?.[list.labelField] as string | null) ?? itemId
766
+ const href = `/${list.path}/${itemId}`
767
+
768
+ for (const error of itemActionErrors) {
769
+ yield (
770
+ <VStack key={itemId} gap="regular">
771
+ <Notice tone="critical">
772
+ <Content>
773
+ <Text>
774
+ You might try running the action again from{' '}
775
+ <TextLink href={href}>
776
+ the {list.singular.toLowerCase()}.
777
+ </TextLink>
778
+ </Text>
779
+ <Box
780
+ elementType="pre"
781
+ backgroundColor="critical"
782
+ borderRadius="regular"
783
+ maxHeight="100%"
784
+ overflow="auto"
785
+ >
786
+ <Text
787
+ color="critical"
788
+ UNSAFE_className={css({
789
+ fontFamily: tokenSchema.typography.fontFamily.code,
790
+ })}
791
+ >
792
+ {error.message}
793
+ </Text>
794
+ </Box>
795
+ </Content>
796
+ <div>
797
+ <Heading>
798
+ {replace(
799
+ action.messages.fail,
800
+ list,
801
+ { ...action, itemLabel },
802
+ false
803
+ )}
804
+ </Heading>
805
+ </div>
806
+ </Notice>
807
+ </VStack>
808
+ )
809
+ }
810
+ }
811
+ })(),
812
+ ]}
813
+ </VStack>
814
+ </Content>
815
+ </Dialog>
816
+ ) : null}
817
+ </DialogContainer>
818
+ </VStack>
819
+ </PageContainer>
820
+ )
821
+ }
822
+
823
+ const LIST_PAGE_TITLE_ID = 'nixxie-list-page-title'
824
+
825
+ function ListPageHeader({ listKey, showCreate }: { listKey: string; showCreate?: boolean }) {
826
+ const list = useList(listKey)
827
+ return (
828
+ <Fragment>
829
+ <Heading id={LIST_PAGE_TITLE_ID} elementType="h1" size="small">
830
+ {list.label}
831
+ </Heading>
832
+ {showCreate && (
833
+ <CreateButtonLink
834
+ list={list}
835
+ >{`New ${list.singular.toLocaleLowerCase()}`}</CreateButtonLink>
836
+ )}
837
+ </Fragment>
838
+ )
839
+ }
840
+
841
+ function replace(
842
+ s: string,
843
+ list: ListMeta,
844
+ args: ActionMeta & {
845
+ itemLabel?: string
846
+ count?: number
847
+ countFail?: number
848
+ countSuccess?: number
849
+ },
850
+ many: boolean
851
+ ) {
852
+ s = s.replaceAll('{Label}', args.label)
853
+ s = s.replaceAll('{label}', args.label.toLowerCase())
854
+ if (s.includes('{singular|plural}'))
855
+ s = s.replaceAll('{singular|plural}', many ? '{plural}' : '{singular}')
856
+ if (s.includes('{Singular}')) s = s.replaceAll('{Singular}', list.singular)
857
+ if (s.includes('{Plural}')) s = s.replaceAll('{Plural}', list.plural)
858
+ if (s.includes('{singular}')) s = s.replaceAll('{singular}', list.singular.toLowerCase())
859
+ if (s.includes('{plural}')) s = s.replaceAll('{plural}', list.plural.toLowerCase())
860
+ if ('count' in args) s = s.replaceAll('{count}', String(args.count))
861
+ if ('countFail' in args) s = s.replaceAll('{countFail}', String(args.countFail))
862
+ if ('countSuccess' in args) s = s.replaceAll('{countSuccess}', String(args.countSuccess))
863
+ if ('itemLabel' in args) s = s.replaceAll('{itemLabel}', args.itemLabel ?? '')
864
+ return s
865
+ }
866
+
867
+ type ActionErrors = Record<string, GraphQLFormattedError[]>
868
+ type ActionErrorResult = {
869
+ action: ActionMeta
870
+ errors: ActionErrors
871
+ }
872
+
873
+ function ActionItemsDialog({
874
+ list,
875
+ itemIds,
876
+ items,
877
+ onSuccess,
878
+ onErrors,
879
+ action,
880
+ }: {
881
+ list: ListMeta
882
+ itemIds: string[]
883
+ items: Record<string, unknown>[]
884
+ onSuccess: (remaining: Set<string>) => void
885
+ onErrors: (result: ActionErrorResult) => void
886
+ action: ActionMeta
887
+ }) {
888
+ const actionMutation =
889
+ action.key === 'delete'
890
+ ? gql`mutation($where: [${list.graphql.names.whereUniqueInputName}!]!) {
891
+ results: ${action.graphql.names.many}(where: $where) {
892
+ id
893
+ }
894
+ }`
895
+ : gql`mutation($data: [${action.graphql.names.one[0].toUpperCase()}${action.graphql.names.one.slice(1)}Args!]!) {
896
+ results: ${action.graphql.names.many}(data: $data) {
897
+ id
898
+ }
899
+ }`
900
+ const [actionOnItems] = useMutation<{ results?: ({ id: string } | null)[] }>(actionMutation, {
901
+ variables:
902
+ action.key === 'delete'
903
+ ? { where: itemIds.map(id => ({ id })) }
904
+ : {
905
+ data: itemIds.flatMap(id => {
906
+ const row = items.find(item => String(item.id) === id)
907
+ if (!row) {
908
+ return []
909
+ }
910
+ const deserialized = deserializeItemToValue(list.fields, row)
911
+ const args = getActionArguments(list, action, deserialized)
912
+ return { where: { id }, ...args }
913
+ }),
914
+ },
915
+ errorPolicy: 'all',
916
+ })
917
+ const { messages: m } = action
918
+
919
+ async function onTryAction() {
920
+ try {
921
+ const { error } = await actionOnItems()
922
+ const failed = new Set<string>()
923
+ const actionErrors: ActionErrors = {}
924
+ let countFail = 0
925
+ if (CombinedGraphQLErrors.is(error)) {
926
+ countFail = error.errors.length
927
+ for (const err of error.errors ?? []) {
928
+ const i = err.path?.[1]
929
+ if (typeof i !== 'number') continue
930
+ const itemId = itemIds[i]
931
+
932
+ failed.add(itemId)
933
+ actionErrors[itemId] ??= []
934
+ actionErrors[itemId].push(err)
935
+ }
936
+ }
937
+ const countSuccess = itemIds.length - countFail
938
+
939
+ if (countSuccess) {
940
+ toastQueue.neutral(
941
+ replace(
942
+ m.successMany,
943
+ list,
944
+ {
945
+ ...action,
946
+ count: itemIds.length,
947
+ countFail,
948
+ countSuccess,
949
+ },
950
+ countSuccess > 1
951
+ ),
952
+ { timeout: 5000 }
953
+ )
954
+ }
955
+
956
+ if (countFail) {
957
+ toastQueue.critical(
958
+ replace(
959
+ m.failMany,
960
+ list,
961
+ {
962
+ ...action,
963
+ count: itemIds.length,
964
+ countFail,
965
+ countSuccess,
966
+ },
967
+ countFail > 1
968
+ ),
969
+ {
970
+ actionLabel: 'Details',
971
+ onAction: () => onErrors({ action, errors: actionErrors }),
972
+ shouldCloseOnAction: true,
973
+ }
974
+ )
975
+ }
976
+
977
+ return onSuccess(failed)
978
+ } catch (error) {
979
+ console.error(error)
980
+ }
981
+ }
982
+
983
+ return (
984
+ <AlertDialog
985
+ tone={action.key === 'delete' ? 'critical' : 'neutral'}
986
+ title={replace(
987
+ m.promptTitleMany,
988
+ list,
989
+ { ...action, count: itemIds.length },
990
+ itemIds.length > 1
991
+ )}
992
+ cancelLabel="Cancel"
993
+ primaryActionLabel={replace(
994
+ m.promptConfirmLabelMany,
995
+ list,
996
+ { ...action, count: itemIds.length },
997
+ itemIds.length > 1
998
+ )}
999
+ onPrimaryAction={onTryAction}
1000
+ >
1001
+ <Text>
1002
+ {replace(m.promptMany, list, { ...action, count: itemIds.length }, itemIds.length > 1)}
1003
+ </Text>
1004
+ </AlertDialog>
1005
+ )
1006
+ }