ding-react-admin 1.0.4 → 2.0.4

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 (230) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +254 -201
  3. package/dist/index.css +2 -0
  4. package/dist/index.js +2073 -1202
  5. package/dist/src/components/NavMenuLabel.d.ts +8 -0
  6. package/dist/src/components/NavMenuLabel.d.ts.map +1 -0
  7. package/dist/src/components/NavMenuSearch.d.ts +9 -0
  8. package/dist/src/components/NavMenuSearch.d.ts.map +1 -0
  9. package/dist/src/components/ScrollableArea.d.ts +10 -0
  10. package/dist/src/components/ScrollableArea.d.ts.map +1 -0
  11. package/dist/src/components/index.d.ts +3 -0
  12. package/dist/src/components/index.d.ts.map +1 -1
  13. package/dist/src/context/AuthProvider.d.ts +2 -0
  14. package/dist/src/context/AuthProvider.d.ts.map +1 -1
  15. package/dist/src/crud/FormGlobalErrorsAlert.d.ts +5 -0
  16. package/dist/src/crud/FormGlobalErrorsAlert.d.ts.map +1 -0
  17. package/dist/src/crud/InlineFormSet.d.ts +2 -17
  18. package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
  19. package/dist/src/crud/ResourceForm.d.ts +2 -10
  20. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  21. package/dist/src/crud/ResourceFormModal.d.ts +7 -1
  22. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  23. package/dist/src/crud/ResourceList.d.ts.map +1 -1
  24. package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
  25. package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
  26. package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
  27. package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
  28. package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
  29. package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
  30. package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
  31. package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
  32. package/dist/src/crud/fields/FileField.d.ts +12 -0
  33. package/dist/src/crud/fields/FileField.d.ts.map +1 -0
  34. package/dist/src/crud/fields/ImageField.d.ts +14 -0
  35. package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
  36. package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
  37. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  38. package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
  39. package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
  40. package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
  41. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  42. package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
  43. package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
  44. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
  45. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
  46. package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
  47. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
  48. package/dist/src/crud/index.d.ts +27 -9
  49. package/dist/src/crud/index.d.ts.map +1 -1
  50. package/dist/src/crud/types.d.ts +21 -7
  51. package/dist/src/crud/types.d.ts.map +1 -1
  52. package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
  53. package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
  54. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
  55. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
  56. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
  57. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
  58. package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
  59. package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
  60. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
  61. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
  62. package/dist/src/crud/utils/formErrors.d.ts +18 -7
  63. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  64. package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
  65. package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
  66. package/dist/src/crud/utils/getFormValue.d.ts +3 -0
  67. package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
  68. package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
  69. package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
  70. package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
  71. package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
  72. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +8 -0
  73. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
  74. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
  75. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
  76. package/dist/src/crud/utils/setFormValue.d.ts +3 -0
  77. package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
  78. package/dist/src/crud/utils/toFormData.d.ts +13 -0
  79. package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
  80. package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
  81. package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
  82. package/dist/src/crud/utils/useChoices.d.ts +27 -1
  83. package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
  84. package/dist/src/crud/utils/useFormRecord.d.ts +44 -0
  85. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
  86. package/dist/src/data/createRestResourceHandlers.d.ts +5 -5
  87. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
  88. package/dist/src/data/dataProviderTypes.d.ts +6 -5
  89. package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
  90. package/dist/src/data/parseFormErrorHelpers.d.ts +23 -6
  91. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  92. package/dist/src/data/resourceHandlers.d.ts +1 -1
  93. package/dist/src/data/resourceHandlers.d.ts.map +1 -1
  94. package/dist/src/index.d.ts +4 -4
  95. package/dist/src/index.d.ts.map +1 -1
  96. package/dist/src/layouts/AdminLayout.d.ts +1 -1
  97. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  98. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
  99. package/dist/src/layouts/navFilter.d.ts +5 -0
  100. package/dist/src/layouts/navFilter.d.ts.map +1 -0
  101. package/dist/src/layouts/navMenuItems.d.ts +6 -0
  102. package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
  103. package/dist/src/types.d.ts +12 -0
  104. package/dist/src/types.d.ts.map +1 -1
  105. package/package.json +79 -59
  106. package/src/app/AdminApp.tsx +34 -34
  107. package/src/components/AuthAlternateLink.tsx +23 -23
  108. package/src/components/DensitySwitch.tsx +31 -31
  109. package/src/components/NavMenuLabel.tsx +26 -0
  110. package/src/components/NavMenuSearch.tsx +76 -0
  111. package/src/components/ScrollableArea.tsx +46 -0
  112. package/src/components/ThemeSwitch.tsx +28 -28
  113. package/src/components/ThemeToolbar.tsx +34 -34
  114. package/src/components/index.ts +7 -4
  115. package/src/components/navMenu.css +21 -0
  116. package/src/components/scrollable.css +28 -0
  117. package/src/context/AppThemeProvider.tsx +128 -128
  118. package/src/context/AuthProvider.tsx +89 -77
  119. package/src/context/DataProvider.tsx +30 -30
  120. package/src/context/PermissionsProvider.tsx +57 -57
  121. package/src/crud/FilterBar.tsx +36 -36
  122. package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
  123. package/src/crud/FormSteps.tsx +135 -135
  124. package/src/crud/FormTabs.tsx +126 -126
  125. package/src/crud/InlineFormSet.tsx +162 -219
  126. package/src/crud/ListActionsBar.tsx +106 -106
  127. package/src/crud/ResourceForm.tsx +83 -166
  128. package/src/crud/ResourceFormModal.tsx +75 -133
  129. package/src/crud/ResourceList.tsx +1 -0
  130. package/src/crud/ResourceRecordForm.tsx +166 -0
  131. package/src/crud/columns/BooleanColumn.tsx +32 -32
  132. package/src/crud/columns/CustomColumn.tsx +33 -33
  133. package/src/crud/columns/DateColumn.tsx +28 -28
  134. package/src/crud/columns/ImageColumn.tsx +58 -0
  135. package/src/crud/columns/NumberColumn.tsx +31 -31
  136. package/src/crud/columns/ReferenceColumn.tsx +85 -85
  137. package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
  138. package/src/crud/columns/TextColumn.tsx +38 -38
  139. package/src/crud/context/FilterContext.tsx +71 -71
  140. package/src/crud/context/FormContext.tsx +39 -39
  141. package/src/crud/context/FormSectionContext.tsx +22 -22
  142. package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
  143. package/src/crud/context/ListContext.tsx +93 -93
  144. package/src/crud/context/PayloadFieldsContext.tsx +62 -0
  145. package/src/crud/fields/BooleanField.tsx +38 -38
  146. package/src/crud/fields/DateField.tsx +53 -53
  147. package/src/crud/fields/FieldWrapper.tsx +87 -87
  148. package/src/crud/fields/FileField.tsx +123 -0
  149. package/src/crud/fields/ImageField.tsx +120 -0
  150. package/src/crud/fields/NumberField.tsx +52 -52
  151. package/src/crud/fields/PasswordField.tsx +99 -99
  152. package/src/crud/fields/ReferenceField.tsx +237 -96
  153. package/src/crud/fields/ReferenceInputActions.tsx +106 -0
  154. package/src/crud/fields/ReferenceManyField.tsx +200 -76
  155. package/src/crud/fields/SelectField.tsx +48 -48
  156. package/src/crud/fields/TextField.tsx +46 -46
  157. package/src/crud/fields/uploadFieldUtils.ts +21 -0
  158. package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
  159. package/src/crud/filters/BooleanFilter.tsx +38 -38
  160. package/src/crud/filters/DateFilter.tsx +35 -35
  161. package/src/crud/filters/NumberFilter.tsx +33 -33
  162. package/src/crud/filters/ReferenceFilter.tsx +192 -173
  163. package/src/crud/filters/SelectFilter.tsx +44 -44
  164. package/src/crud/filters/TextFilter.tsx +36 -36
  165. package/src/crud/index.ts +99 -90
  166. package/src/crud/types.ts +146 -132
  167. package/src/crud/utils/buildFormPayload.ts +24 -0
  168. package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
  169. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
  170. package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
  171. package/src/crud/utils/choiceSelectionUtils.ts +116 -0
  172. package/src/crud/utils/formErrors.test.ts +168 -0
  173. package/src/crud/utils/formErrors.ts +138 -38
  174. package/src/crud/utils/formSectionErrors.ts +85 -85
  175. package/src/crud/utils/{getByPath.ts → getFormValue.ts} +13 -13
  176. package/src/crud/utils/hasUploadValues.ts +9 -0
  177. package/src/crud/utils/nestedFieldPath.ts +8 -0
  178. package/src/crud/utils/prepareFormSubmitBody.ts +22 -0
  179. package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
  180. package/src/crud/utils/{setByPath.ts → setFormValue.ts} +18 -18
  181. package/src/crud/utils/sortQueryParam.ts +37 -37
  182. package/src/crud/utils/toFormData.ts +79 -0
  183. package/src/crud/utils/uploadReferenceUtils.ts +44 -0
  184. package/src/crud/utils/useAbortableEffect.ts +17 -17
  185. package/src/crud/utils/useChoices.ts +330 -182
  186. package/src/crud/utils/useFormRecord.ts +179 -0
  187. package/src/crud/utils/useListQueryState.ts +191 -191
  188. package/src/data/abortError.test.ts +19 -19
  189. package/src/data/abortError.ts +10 -10
  190. package/src/data/createMemoryResourceHandlers.ts +78 -78
  191. package/src/data/createRestResourceHandlers.ts +63 -57
  192. package/src/data/dataProviderTypes.ts +7 -5
  193. package/src/data/dataUtils.test.ts +228 -108
  194. package/src/data/inMemoryList.ts +97 -97
  195. package/src/data/parseFormErrorHelpers.test.ts +201 -84
  196. package/src/data/parseFormErrorHelpers.ts +376 -224
  197. package/src/data/resourceHandlers.ts +1 -1
  198. package/src/data/sortHelpers.ts +48 -48
  199. package/src/index.ts +211 -195
  200. package/src/layouts/AdminLayout.tsx +530 -381
  201. package/src/layouts/AuthPageLayout.tsx +87 -81
  202. package/src/layouts/navFilter.ts +49 -0
  203. package/src/layouts/navMenuItems.tsx +44 -0
  204. package/src/pages/LoginPage.tsx +80 -80
  205. package/src/pages/PlaceholderPage.tsx +9 -9
  206. package/src/permissions/resourcePermissions.ts +47 -47
  207. package/src/router/createAdminRouter.tsx +64 -64
  208. package/src/router/guards.tsx +63 -63
  209. package/src/router/routeAccess.ts +89 -89
  210. package/src/types.ts +10 -0
  211. package/src/vite-env.d.ts +1 -1
  212. package/dist/src/crud/context/SubmitFieldsContext.d.ts +0 -18
  213. package/dist/src/crud/context/SubmitFieldsContext.d.ts.map +0 -1
  214. package/dist/src/crud/utils/getByPath.d.ts +0 -3
  215. package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
  216. package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
  217. package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
  218. package/dist/src/crud/utils/inlineFieldName.d.ts +0 -3
  219. package/dist/src/crud/utils/inlineFieldName.d.ts.map +0 -1
  220. package/dist/src/crud/utils/pickBySources.d.ts +0 -6
  221. package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
  222. package/dist/src/crud/utils/setByPath.d.ts +0 -3
  223. package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
  224. package/dist/src/crud/utils/useResourceFormData.d.ts +0 -47
  225. package/dist/src/crud/utils/useResourceFormData.d.ts.map +0 -1
  226. package/src/crud/context/SubmitFieldsContext.tsx +0 -62
  227. package/src/crud/utils/inlineArrayName.ts +0 -4
  228. package/src/crud/utils/inlineFieldName.ts +0 -8
  229. package/src/crud/utils/pickBySources.ts +0 -24
  230. package/src/crud/utils/useResourceFormData.ts +0 -231
@@ -1,182 +1,330 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
2
- import { useDataProvider } from "../../context/DataProvider";
3
- import type { ChoiceOption, ChoicesLoader } from "../types";
4
-
5
- const choicesCache = new Map<string, ChoiceOption[]>();
6
- const choicesInflight = new Map<string, Promise<ChoiceOption[]>>();
7
-
8
- function loaderKey(loader: ChoicesLoader, search?: string): string {
9
- if (typeof loader === "function") return `fn:${search ?? ""}`;
10
- if (Array.isArray(loader)) return `static:${loader.length}`;
11
- return `res:${loader.resource}:${JSON.stringify(loader.filter ?? {})}:${search ?? ""}`;
12
- }
13
-
14
- function resolveOptionLabel(
15
- record: Record<string, unknown>,
16
- optionLabel: string | ((record: Record<string, unknown>) => string),
17
- ): string {
18
- if (typeof optionLabel === "function") return optionLabel(record);
19
- return String(record[optionLabel] ?? "");
20
- }
21
-
22
- async function fetchChoices(
23
- effectiveLoader: ChoicesLoader,
24
- dp: ReturnType<typeof useDataProvider>,
25
- optionLabel: string | ((record: Record<string, unknown>) => string),
26
- optionValue: string,
27
- search?: string,
28
- ): Promise<ChoiceOption[]> {
29
- if (typeof effectiveLoader === "function") {
30
- return effectiveLoader({ dataProvider: dp, search });
31
- }
32
- if (Array.isArray(effectiveLoader)) {
33
- return effectiveLoader;
34
- }
35
- const res = await dp.getList(effectiveLoader.resource, {
36
- filter: {
37
- ...effectiveLoader.filter,
38
- ...(search ? { q: search } : {}),
39
- },
40
- pagination: { page: 1, perPage: 500 },
41
- });
42
- return (res.data as Record<string, unknown>[]).map((row) => ({
43
- label: resolveOptionLabel(row, optionLabel),
44
- value: row[optionValue],
45
- record: row,
46
- }));
47
- }
48
-
49
- function loadChoicesShared(
50
- effectiveLoader: ChoicesLoader,
51
- dp: ReturnType<typeof useDataProvider>,
52
- optionLabel: string | ((record: Record<string, unknown>) => string),
53
- optionValue: string,
54
- search?: string,
55
- ): Promise<ChoiceOption[]> {
56
- const key = loaderKey(effectiveLoader, search);
57
- const cached = choicesCache.get(key);
58
- if (cached && !search) {
59
- return Promise.resolve(cached);
60
- }
61
-
62
- const inflight = choicesInflight.get(key);
63
- if (inflight) return inflight;
64
-
65
- const promise = fetchChoices(
66
- effectiveLoader,
67
- dp,
68
- optionLabel,
69
- optionValue,
70
- search,
71
- )
72
- .then((result) => {
73
- if (!search) choicesCache.set(key, result);
74
- return result;
75
- })
76
- .finally(() => {
77
- choicesInflight.delete(key);
78
- });
79
-
80
- choicesInflight.set(key, promise);
81
- return promise;
82
- }
83
-
84
- export function useChoices(
85
- loader: ChoicesLoader | undefined,
86
- reference: string | undefined,
87
- optionLabel: string | ((record: Record<string, unknown>) => string) = "name",
88
- optionValue = "id",
89
- search?: string,
90
- ) {
91
- const dp = useDataProvider();
92
-
93
- const effectiveLoader = useMemo((): ChoicesLoader | undefined => {
94
- if (loader) return loader;
95
- if (!reference) return undefined;
96
- return {
97
- resource: reference,
98
- filter: search ? { q: search } : undefined,
99
- };
100
- }, [loader, reference, search]);
101
-
102
- const cacheKey = effectiveLoader
103
- ? loaderKey(effectiveLoader, search)
104
- : undefined;
105
-
106
- const [options, setOptions] = useState<ChoiceOption[]>(() => {
107
- if (!cacheKey || search) return [];
108
- return choicesCache.get(cacheKey) ?? [];
109
- });
110
-
111
- const [loading, setLoading] = useState(() => {
112
- if (!cacheKey || search) return Boolean(effectiveLoader);
113
- return !choicesCache.has(cacheKey);
114
- });
115
-
116
- const load = useCallback(async () => {
117
- if (!effectiveLoader) {
118
- setOptions([]);
119
- setLoading(false);
120
- return;
121
- }
122
-
123
- const key = loaderKey(effectiveLoader, search);
124
- const cached = choicesCache.get(key);
125
- if (cached && !search) {
126
- setOptions(cached);
127
- setLoading(false);
128
- return;
129
- }
130
-
131
- setLoading(true);
132
- try {
133
- const result = await loadChoicesShared(
134
- effectiveLoader,
135
- dp,
136
- optionLabel,
137
- optionValue,
138
- search,
139
- );
140
- setOptions(result);
141
- } catch {
142
- setOptions([]);
143
- } finally {
144
- setLoading(false);
145
- }
146
- }, [effectiveLoader, dp, optionLabel, optionValue, search]);
147
-
148
- useEffect(() => {
149
- void load();
150
- }, [load]);
151
-
152
- const labelForValue = useCallback(
153
- (value: unknown): string => {
154
- const opt = options.find((o) => o.value === value);
155
- return opt?.label ?? String(value ?? "—");
156
- },
157
- [options],
158
- );
159
-
160
- const labelsForValues = useCallback(
161
- (values: unknown[]): string => {
162
- if (!values?.length) return "—";
163
- return values.map((v) => labelForValue(v)).join(", ");
164
- },
165
- [labelForValue],
166
- );
167
-
168
- const optionForValue = useCallback(
169
- (value: unknown): ChoiceOption | undefined =>
170
- options.find((o) => o.value === value),
171
- [options],
172
- );
173
-
174
- return {
175
- options,
176
- loading,
177
- labelForValue,
178
- labelsForValues,
179
- optionForValue,
180
- reload: load,
181
- };
182
- }
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useDataProvider } from "../../context/DataProvider";
3
+ import type { Identifier } from "../../data/dataProviderTypes";
4
+ import type { ChoiceOption, ChoicesLoader } from "../types";
5
+ import {
6
+ collectSelectedRecords,
7
+ mergeOptions,
8
+ normalizeSelectedIds,
9
+ recordsToOptions,
10
+ resolveOptionLabel,
11
+ } from "./choiceSelectionUtils";
12
+
13
+ const choicesCache = new Map<string, ChoiceOption[]>();
14
+ const choicesInflight = new Map<string, Promise<ChoiceOption[]>>();
15
+
16
+ export type UseChoicesOptions = {
17
+ /** When true, the option list loads only while the dropdown is open or the user is searching. */
18
+ lazy?: boolean;
19
+ /** Dropdown open or active search — triggers lazy list loading. */
20
+ active?: boolean;
21
+ /**
22
+ * Current field value — primitive id(s), nested object(s), or mixed.
23
+ * Objects shaped like `{ id, name, … }` are turned into options without `getOne`.
24
+ */
25
+ selectedValues?: unknown | unknown[];
26
+ /**
27
+ * Related record(s) from the loaded form row (e.g. `recordSource="branch"`).
28
+ * Used to show labels on edit when the retrieve API embeds relations.
29
+ */
30
+ selectedRecords?: Record<string, unknown> | Record<string, unknown>[];
31
+ /**
32
+ * When true (default), fetch labels for primitive ids via `getOne` if they are
33
+ * not already known from `selectedValues` / `selectedRecords`.
34
+ */
35
+ fetchSelected?: boolean;
36
+ /**
37
+ * Cache list results in memory. Defaults to `false` when `lazy` is true so each
38
+ * dropdown open refetches; eager loads (e.g. table columns) default to `true`.
39
+ */
40
+ cache?: boolean;
41
+ };
42
+
43
+ function loaderKey(loader: ChoicesLoader, search?: string): string {
44
+ if (typeof loader === "function") return `fn:${search ?? ""}`;
45
+ if (Array.isArray(loader)) return `static:${loader.length}`;
46
+ return `res:${loader.resource}:${JSON.stringify(loader.filter ?? {})}:${search ?? ""}`;
47
+ }
48
+
49
+ async function fetchChoices(
50
+ effectiveLoader: ChoicesLoader,
51
+ dp: ReturnType<typeof useDataProvider>,
52
+ optionLabel: string | ((record: Record<string, unknown>) => string),
53
+ optionValue: string,
54
+ search?: string,
55
+ ): Promise<ChoiceOption[]> {
56
+ if (typeof effectiveLoader === "function") {
57
+ return effectiveLoader({ dataProvider: dp, search });
58
+ }
59
+ if (Array.isArray(effectiveLoader)) {
60
+ return effectiveLoader;
61
+ }
62
+ const res = await dp.getList(effectiveLoader.resource, {
63
+ filter: {
64
+ ...effectiveLoader.filter,
65
+ ...(search ? { q: search } : {}),
66
+ },
67
+ pagination: { page: 1, perPage: 500 },
68
+ });
69
+ return (res.data as Record<string, unknown>[]).map((row) => ({
70
+ label: resolveOptionLabel(row, optionLabel),
71
+ value: row[optionValue],
72
+ record: row,
73
+ }));
74
+ }
75
+
76
+ function loadChoicesShared(
77
+ effectiveLoader: ChoicesLoader,
78
+ dp: ReturnType<typeof useDataProvider>,
79
+ optionLabel: string | ((record: Record<string, unknown>) => string),
80
+ optionValue: string,
81
+ search: string | undefined,
82
+ useCache: boolean,
83
+ ): Promise<ChoiceOption[]> {
84
+ const key = loaderKey(effectiveLoader, search);
85
+ if (useCache) {
86
+ const cached = choicesCache.get(key);
87
+ if (cached && !search) {
88
+ return Promise.resolve(cached);
89
+ }
90
+ }
91
+
92
+ const inflight = choicesInflight.get(key);
93
+ if (inflight) return inflight;
94
+
95
+ const promise = fetchChoices(
96
+ effectiveLoader,
97
+ dp,
98
+ optionLabel,
99
+ optionValue,
100
+ search,
101
+ )
102
+ .then((result) => {
103
+ if (useCache && !search) choicesCache.set(key, result);
104
+ return result;
105
+ })
106
+ .finally(() => {
107
+ choicesInflight.delete(key);
108
+ });
109
+
110
+ choicesInflight.set(key, promise);
111
+ return promise;
112
+ }
113
+
114
+ export function useChoices(
115
+ loader: ChoicesLoader | undefined,
116
+ reference: string | undefined,
117
+ optionLabel: string | ((record: Record<string, unknown>) => string) = "name",
118
+ optionValue = "id",
119
+ search?: string,
120
+ hookOptions: UseChoicesOptions = {},
121
+ ) {
122
+ const {
123
+ lazy = false,
124
+ active = false,
125
+ selectedValues,
126
+ selectedRecords,
127
+ fetchSelected = true,
128
+ cache: cacheOption,
129
+ } = hookOptions;
130
+ const useCache = cacheOption ?? !lazy;
131
+ const dp = useDataProvider();
132
+
133
+ const effectiveLoader = useMemo((): ChoicesLoader | undefined => {
134
+ if (loader) return loader;
135
+ if (!reference) return undefined;
136
+ return {
137
+ resource: reference,
138
+ filter: search ? { q: search } : undefined,
139
+ };
140
+ }, [loader, reference, search]);
141
+
142
+ const cacheKey = effectiveLoader
143
+ ? loaderKey(effectiveLoader, search)
144
+ : undefined;
145
+
146
+ const normalizedSelected = useMemo(
147
+ () => normalizeSelectedIds(selectedValues, optionValue),
148
+ [selectedValues, optionValue],
149
+ );
150
+
151
+ const embeddedOptions = useMemo(
152
+ () =>
153
+ recordsToOptions(
154
+ collectSelectedRecords(selectedValues, selectedRecords, optionValue),
155
+ optionLabel,
156
+ optionValue,
157
+ ),
158
+ [selectedValues, selectedRecords, optionLabel, optionValue],
159
+ );
160
+
161
+ const shouldLoadList = Boolean(
162
+ effectiveLoader && (!lazy || active || Array.isArray(effectiveLoader)),
163
+ );
164
+
165
+ const [options, setOptions] = useState<ChoiceOption[]>(() => {
166
+ if (embeddedOptions.length) return embeddedOptions;
167
+ if (!cacheKey || search || lazy || !useCache) return [];
168
+ return choicesCache.get(cacheKey) ?? [];
169
+ });
170
+
171
+ const [loading, setLoading] = useState(() => {
172
+ if (!shouldLoadList) return false;
173
+ if (!useCache || !cacheKey || search) return Boolean(effectiveLoader);
174
+ return !choicesCache.has(cacheKey);
175
+ });
176
+
177
+ useEffect(() => {
178
+ if (!embeddedOptions.length) return;
179
+ setOptions((prev) => mergeOptions(prev, embeddedOptions));
180
+ }, [embeddedOptions]);
181
+
182
+ const load = useCallback(async () => {
183
+ if (!effectiveLoader || !shouldLoadList) {
184
+ if (!effectiveLoader) {
185
+ setOptions(embeddedOptions);
186
+ }
187
+ setLoading(false);
188
+ return;
189
+ }
190
+
191
+ if (useCache) {
192
+ const key = loaderKey(effectiveLoader, search);
193
+ const cached = choicesCache.get(key);
194
+ if (cached && !search) {
195
+ setOptions(mergeOptions(embeddedOptions, cached));
196
+ setLoading(false);
197
+ return;
198
+ }
199
+ }
200
+
201
+ setLoading(true);
202
+ if (lazy) {
203
+ setOptions(embeddedOptions);
204
+ }
205
+
206
+ try {
207
+ const result = await loadChoicesShared(
208
+ effectiveLoader,
209
+ dp,
210
+ optionLabel,
211
+ optionValue,
212
+ search,
213
+ useCache,
214
+ );
215
+ setOptions(mergeOptions(embeddedOptions, result));
216
+ } catch {
217
+ if (!normalizedSelected.length && !embeddedOptions.length) {
218
+ setOptions([]);
219
+ } else if (lazy) {
220
+ setOptions(embeddedOptions);
221
+ }
222
+ } finally {
223
+ setLoading(false);
224
+ }
225
+ }, [
226
+ effectiveLoader,
227
+ shouldLoadList,
228
+ useCache,
229
+ dp,
230
+ optionLabel,
231
+ optionValue,
232
+ search,
233
+ lazy,
234
+ normalizedSelected.length,
235
+ embeddedOptions,
236
+ ]);
237
+
238
+ useEffect(() => {
239
+ void load();
240
+ }, [load]);
241
+
242
+ useEffect(() => {
243
+ if (lazy && !active && !search) {
244
+ setOptions(embeddedOptions);
245
+ setLoading(false);
246
+ }
247
+ }, [lazy, active, search, embeddedOptions]);
248
+
249
+ useEffect(() => {
250
+ if (!fetchSelected || !reference || !normalizedSelected.length) return;
251
+
252
+ const idsToFetch = normalizedSelected.filter(
253
+ (value) => !embeddedOptions.some((option) => option.value === value),
254
+ );
255
+ if (!idsToFetch.length) return;
256
+
257
+ let cancelled = false;
258
+
259
+ void (async () => {
260
+ const resolved: ChoiceOption[] = [];
261
+ for (const value of idsToFetch) {
262
+ try {
263
+ const result = await dp.getOne(reference, value as Identifier);
264
+ const row = result.data as Record<string, unknown>;
265
+ resolved.push({
266
+ label: resolveOptionLabel(row, optionLabel),
267
+ value: row[optionValue],
268
+ record: row,
269
+ });
270
+ } catch {
271
+ resolved.push({
272
+ label: String(value),
273
+ value,
274
+ });
275
+ }
276
+ }
277
+ if (cancelled || !resolved.length) return;
278
+
279
+ setOptions((prev) => {
280
+ const missing = resolved.filter(
281
+ (option) => !prev.some((existing) => existing.value === option.value),
282
+ );
283
+ return missing.length ? mergeOptions(prev, missing) : prev;
284
+ });
285
+ })();
286
+
287
+ return () => {
288
+ cancelled = true;
289
+ };
290
+ }, [
291
+ fetchSelected,
292
+ reference,
293
+ dp,
294
+ optionLabel,
295
+ optionValue,
296
+ normalizedSelected,
297
+ embeddedOptions,
298
+ ]);
299
+
300
+ const labelForValue = useCallback(
301
+ (value: unknown): string => {
302
+ const opt = options.find((o) => o.value === value);
303
+ return opt?.label ?? String(value ?? "—");
304
+ },
305
+ [options],
306
+ );
307
+
308
+ const labelsForValues = useCallback(
309
+ (values: unknown[]): string => {
310
+ if (!values?.length) return "—";
311
+ return values.map((v) => labelForValue(v)).join(", ");
312
+ },
313
+ [labelForValue],
314
+ );
315
+
316
+ const optionForValue = useCallback(
317
+ (value: unknown): ChoiceOption | undefined =>
318
+ options.find((o) => o.value === value),
319
+ [options],
320
+ );
321
+
322
+ return {
323
+ options,
324
+ loading,
325
+ labelForValue,
326
+ labelsForValues,
327
+ optionForValue,
328
+ reload: load,
329
+ };
330
+ }