ding-react-admin 2.0.0 → 2.0.5

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 (169) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +254 -195
  3. package/dist/index.css +1 -0
  4. package/dist/index.js +1773 -1170
  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/ResourceForm.d.ts +1 -4
  18. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  19. package/dist/src/crud/ResourceFormModal.d.ts +7 -1
  20. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  21. package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
  22. package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
  23. package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
  24. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  25. package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
  26. package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
  27. package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
  28. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  29. package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
  30. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
  31. package/dist/src/crud/index.d.ts +5 -0
  32. package/dist/src/crud/index.d.ts.map +1 -1
  33. package/dist/src/crud/types.d.ts +12 -0
  34. package/dist/src/crud/types.d.ts.map +1 -1
  35. package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
  36. package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
  37. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
  38. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
  39. package/dist/src/crud/utils/formErrors.d.ts +18 -7
  40. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  41. package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
  42. package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
  43. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +1 -0
  44. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -1
  45. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
  46. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
  47. package/dist/src/crud/utils/toFormData.d.ts +1 -1
  48. package/dist/src/crud/utils/toFormData.d.ts.map +1 -1
  49. package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
  50. package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
  51. package/dist/src/crud/utils/useChoices.d.ts +27 -1
  52. package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
  53. package/dist/src/crud/utils/useFormRecord.d.ts +9 -7
  54. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -1
  55. package/dist/src/data/parseFormErrorHelpers.d.ts +6 -27
  56. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  57. package/dist/src/index.d.ts +2 -2
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/dist/src/layouts/AdminLayout.d.ts +1 -1
  60. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  61. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
  62. package/dist/src/layouts/navFilter.d.ts +5 -0
  63. package/dist/src/layouts/navFilter.d.ts.map +1 -0
  64. package/dist/src/layouts/navMenuItems.d.ts +6 -0
  65. package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
  66. package/dist/src/types.d.ts +12 -0
  67. package/dist/src/types.d.ts.map +1 -1
  68. package/package.json +85 -59
  69. package/src/app/AdminApp.tsx +34 -34
  70. package/src/components/AuthAlternateLink.tsx +23 -23
  71. package/src/components/DensitySwitch.tsx +31 -31
  72. package/src/components/NavMenuLabel.tsx +26 -0
  73. package/src/components/NavMenuSearch.tsx +76 -0
  74. package/src/components/ScrollableArea.tsx +46 -0
  75. package/src/components/ThemeSwitch.tsx +28 -28
  76. package/src/components/ThemeToolbar.tsx +34 -34
  77. package/src/components/index.ts +7 -4
  78. package/src/components/navMenu.css +21 -0
  79. package/src/components/scrollable.css +28 -0
  80. package/src/context/AppThemeProvider.tsx +128 -128
  81. package/src/context/AuthProvider.tsx +89 -77
  82. package/src/context/DataProvider.tsx +30 -30
  83. package/src/context/PermissionsProvider.tsx +57 -57
  84. package/src/crud/FilterBar.tsx +36 -36
  85. package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
  86. package/src/crud/FormSteps.tsx +135 -135
  87. package/src/crud/FormTabs.tsx +126 -126
  88. package/src/crud/InlineFormSet.tsx +162 -162
  89. package/src/crud/ListActionsBar.tsx +106 -106
  90. package/src/crud/ResourceForm.tsx +83 -160
  91. package/src/crud/ResourceFormModal.tsx +75 -133
  92. package/src/crud/ResourceRecordForm.tsx +166 -0
  93. package/src/crud/columns/BooleanColumn.tsx +32 -32
  94. package/src/crud/columns/CustomColumn.tsx +33 -33
  95. package/src/crud/columns/DateColumn.tsx +28 -28
  96. package/src/crud/columns/ImageColumn.tsx +58 -58
  97. package/src/crud/columns/NumberColumn.tsx +31 -31
  98. package/src/crud/columns/ReferenceColumn.tsx +85 -85
  99. package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
  100. package/src/crud/columns/TextColumn.tsx +38 -38
  101. package/src/crud/context/FilterContext.tsx +71 -71
  102. package/src/crud/context/FormContext.tsx +39 -39
  103. package/src/crud/context/FormSectionContext.tsx +22 -22
  104. package/src/crud/context/InlineFieldsRegistry.tsx +61 -61
  105. package/src/crud/context/ListContext.tsx +93 -93
  106. package/src/crud/fields/BooleanField.tsx +38 -38
  107. package/src/crud/fields/DateField.tsx +53 -53
  108. package/src/crud/fields/FileField.tsx +123 -123
  109. package/src/crud/fields/ImageField.tsx +120 -120
  110. package/src/crud/fields/NumberField.tsx +52 -52
  111. package/src/crud/fields/PasswordField.tsx +99 -99
  112. package/src/crud/fields/ReferenceField.tsx +237 -96
  113. package/src/crud/fields/ReferenceInputActions.tsx +106 -0
  114. package/src/crud/fields/ReferenceManyField.tsx +200 -76
  115. package/src/crud/fields/SelectField.tsx +48 -48
  116. package/src/crud/fields/TextField.tsx +46 -46
  117. package/src/crud/fields/uploadFieldUtils.ts +21 -21
  118. package/src/crud/fields/useUploadPreviewUrl.ts +20 -20
  119. package/src/crud/filters/BooleanFilter.tsx +38 -38
  120. package/src/crud/filters/DateFilter.tsx +35 -35
  121. package/src/crud/filters/NumberFilter.tsx +33 -33
  122. package/src/crud/filters/ReferenceFilter.tsx +192 -173
  123. package/src/crud/filters/SelectFilter.tsx +44 -44
  124. package/src/crud/filters/TextFilter.tsx +36 -36
  125. package/src/crud/index.ts +99 -94
  126. package/src/crud/types.ts +146 -134
  127. package/src/crud/utils/buildFormPayload.ts +24 -24
  128. package/src/crud/utils/buildInlineRowsPayload.ts +37 -37
  129. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -29
  130. package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
  131. package/src/crud/utils/choiceSelectionUtils.ts +116 -0
  132. package/src/crud/utils/formErrors.test.ts +168 -0
  133. package/src/crud/utils/formErrors.ts +138 -42
  134. package/src/crud/utils/formSectionErrors.ts +85 -85
  135. package/src/crud/utils/getFormValue.ts +13 -13
  136. package/src/crud/utils/hasUploadValues.ts +9 -9
  137. package/src/crud/utils/nestedFieldPath.ts +8 -8
  138. package/src/crud/utils/prepareFormSubmitBody.ts +22 -17
  139. package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
  140. package/src/crud/utils/setFormValue.ts +18 -18
  141. package/src/crud/utils/sortQueryParam.ts +37 -37
  142. package/src/crud/utils/toFormData.ts +79 -81
  143. package/src/crud/utils/uploadReferenceUtils.ts +44 -0
  144. package/src/crud/utils/useAbortableEffect.ts +17 -17
  145. package/src/crud/utils/useChoices.ts +330 -182
  146. package/src/crud/utils/useFormRecord.ts +179 -156
  147. package/src/crud/utils/useListQueryState.ts +191 -191
  148. package/src/data/abortError.test.ts +19 -19
  149. package/src/data/abortError.ts +10 -10
  150. package/src/data/createMemoryResourceHandlers.ts +78 -78
  151. package/src/data/createRestResourceHandlers.ts +63 -63
  152. package/src/data/dataUtils.test.ts +228 -220
  153. package/src/data/inMemoryList.ts +97 -97
  154. package/src/data/parseFormErrorHelpers.test.ts +201 -138
  155. package/src/data/parseFormErrorHelpers.ts +376 -311
  156. package/src/data/sortHelpers.ts +48 -48
  157. package/src/index.ts +211 -207
  158. package/src/layouts/AdminLayout.tsx +530 -382
  159. package/src/layouts/AuthPageLayout.tsx +87 -81
  160. package/src/layouts/navFilter.ts +49 -0
  161. package/src/layouts/navMenuItems.tsx +44 -0
  162. package/src/pages/LoginPage.tsx +80 -80
  163. package/src/pages/PlaceholderPage.tsx +9 -9
  164. package/src/permissions/resourcePermissions.ts +47 -47
  165. package/src/router/createAdminRouter.tsx +64 -64
  166. package/src/router/guards.tsx +63 -63
  167. package/src/router/routeAccess.ts +89 -89
  168. package/src/types.ts +10 -0
  169. package/src/vite-env.d.ts +1 -1
@@ -1,85 +1,85 @@
1
- import { useEffect, useRef, type MutableRefObject } from "react";
2
- import {
3
- useFormContext,
4
- useFormState,
5
- type FieldPath,
6
- type FieldValues,
7
- type FormState,
8
- type UseFormGetFieldState,
9
- } from "react-hook-form";
10
-
11
- export function sectionHasError<T extends FieldValues>(
12
- sources: Set<string>,
13
- getFieldState: UseFormGetFieldState<T>,
14
- formState: FormState<T>,
15
- ): boolean {
16
- for (const source of sources) {
17
- if (getFieldState(source as FieldPath<T>, formState).invalid) {
18
- return true;
19
- }
20
- }
21
- return false;
22
- }
23
-
24
- export function useSectionSourceRefs(count: number) {
25
- const refs = useRef<MutableRefObject<Set<string>>[]>([]);
26
-
27
- while (refs.current.length < count) {
28
- refs.current.push({ current: new Set() });
29
- }
30
- if (refs.current.length > count) {
31
- refs.current.length = count;
32
- }
33
-
34
- return refs.current;
35
- }
36
-
37
- export function useActivateFirstErrorSection(
38
- sectionRefs: MutableRefObject<Set<string>>[],
39
- activate: (index: number) => void,
40
- ) {
41
- const { control, getFieldState, setFocus } = useFormContext<FieldValues>();
42
- const formState = useFormState({ control });
43
- const prevSubmitCount = useRef(0);
44
- const prevErrorCount = useRef(0);
45
-
46
- useEffect(() => {
47
- if (formState.submitCount === 0) return;
48
-
49
- const errorCount = Object.keys(formState.errors).length;
50
- const submitChanged = formState.submitCount !== prevSubmitCount.current;
51
- const serverErrorsArrived =
52
- !submitChanged && errorCount > 0 && prevErrorCount.current === 0;
53
-
54
- prevSubmitCount.current = formState.submitCount;
55
- prevErrorCount.current = errorCount;
56
-
57
- if ((!submitChanged && !serverErrorsArrived) || errorCount === 0) {
58
- return;
59
- }
60
-
61
- const first = sectionRefs.findIndex((ref) =>
62
- sectionHasError(ref.current, getFieldState, formState),
63
- );
64
- if (first < 0) return;
65
-
66
- activate(first);
67
-
68
- const firstInvalid = [...sectionRefs[first].current].find((source) =>
69
- getFieldState(source as FieldPath<FieldValues>, formState).invalid,
70
- );
71
- if (firstInvalid) {
72
- requestAnimationFrame(() => {
73
- void setFocus(firstInvalid as FieldPath<FieldValues>);
74
- });
75
- }
76
- }, [
77
- activate,
78
- formState,
79
- formState.errors,
80
- formState.submitCount,
81
- getFieldState,
82
- sectionRefs,
83
- setFocus,
84
- ]);
85
- }
1
+ import { useEffect, useRef, type MutableRefObject } from "react";
2
+ import {
3
+ useFormContext,
4
+ useFormState,
5
+ type FieldPath,
6
+ type FieldValues,
7
+ type FormState,
8
+ type UseFormGetFieldState,
9
+ } from "react-hook-form";
10
+
11
+ export function sectionHasError<T extends FieldValues>(
12
+ sources: Set<string>,
13
+ getFieldState: UseFormGetFieldState<T>,
14
+ formState: FormState<T>,
15
+ ): boolean {
16
+ for (const source of sources) {
17
+ if (getFieldState(source as FieldPath<T>, formState).invalid) {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function useSectionSourceRefs(count: number) {
25
+ const refs = useRef<MutableRefObject<Set<string>>[]>([]);
26
+
27
+ while (refs.current.length < count) {
28
+ refs.current.push({ current: new Set() });
29
+ }
30
+ if (refs.current.length > count) {
31
+ refs.current.length = count;
32
+ }
33
+
34
+ return refs.current;
35
+ }
36
+
37
+ export function useActivateFirstErrorSection(
38
+ sectionRefs: MutableRefObject<Set<string>>[],
39
+ activate: (index: number) => void,
40
+ ) {
41
+ const { control, getFieldState, setFocus } = useFormContext<FieldValues>();
42
+ const formState = useFormState({ control });
43
+ const prevSubmitCount = useRef(0);
44
+ const prevErrorCount = useRef(0);
45
+
46
+ useEffect(() => {
47
+ if (formState.submitCount === 0) return;
48
+
49
+ const errorCount = Object.keys(formState.errors).length;
50
+ const submitChanged = formState.submitCount !== prevSubmitCount.current;
51
+ const serverErrorsArrived =
52
+ !submitChanged && errorCount > 0 && prevErrorCount.current === 0;
53
+
54
+ prevSubmitCount.current = formState.submitCount;
55
+ prevErrorCount.current = errorCount;
56
+
57
+ if ((!submitChanged && !serverErrorsArrived) || errorCount === 0) {
58
+ return;
59
+ }
60
+
61
+ const first = sectionRefs.findIndex((ref) =>
62
+ sectionHasError(ref.current, getFieldState, formState),
63
+ );
64
+ if (first < 0) return;
65
+
66
+ activate(first);
67
+
68
+ const firstInvalid = [...sectionRefs[first].current].find((source) =>
69
+ getFieldState(source as FieldPath<FieldValues>, formState).invalid,
70
+ );
71
+ if (firstInvalid) {
72
+ requestAnimationFrame(() => {
73
+ void setFocus(firstInvalid as FieldPath<FieldValues>);
74
+ });
75
+ }
76
+ }, [
77
+ activate,
78
+ formState,
79
+ formState.errors,
80
+ formState.submitCount,
81
+ getFieldState,
82
+ sectionRefs,
83
+ setFocus,
84
+ ]);
85
+ }
@@ -1,13 +1,13 @@
1
- /** Read a nested value from form values or a record, e.g. `lines.0.label`. */
2
- export function getFormValue(
3
- record: Record<string, unknown>,
4
- path: string,
5
- ): unknown {
6
- const parts = path.split(".");
7
- let cur: unknown = record;
8
- for (const part of parts) {
9
- if (cur == null || typeof cur !== "object") return undefined;
10
- cur = (cur as Record<string, unknown>)[part];
11
- }
12
- return cur;
13
- }
1
+ /** Read a nested value from form values or a record, e.g. `lines.0.label`. */
2
+ export function getFormValue(
3
+ record: Record<string, unknown>,
4
+ path: string,
5
+ ): unknown {
6
+ const parts = path.split(".");
7
+ let cur: unknown = record;
8
+ for (const part of parts) {
9
+ if (cur == null || typeof cur !== "object") return undefined;
10
+ cur = (cur as Record<string, unknown>)[part];
11
+ }
12
+ return cur;
13
+ }
@@ -1,9 +1,9 @@
1
- /** Whether a save payload contains a `File` or `Blob` anywhere (including inline rows). */
2
- export function hasUploadValues(value: unknown): boolean {
3
- if (value instanceof Blob) return true;
4
- if (Array.isArray(value)) return value.some(hasUploadValues);
5
- if (value && typeof value === "object") {
6
- return Object.values(value as Record<string, unknown>).some(hasUploadValues);
7
- }
8
- return false;
9
- }
1
+ /** Whether a save payload contains a `File` or `Blob` anywhere (including inline rows). */
2
+ export function hasUploadValues(value: unknown): boolean {
3
+ if (value instanceof Blob) return true;
4
+ if (Array.isArray(value)) return value.some(hasUploadValues);
5
+ if (value && typeof value === "object") {
6
+ return Object.values(value as Record<string, unknown>).some(hasUploadValues);
7
+ }
8
+ return false;
9
+ }
@@ -1,8 +1,8 @@
1
- /** RHF path for one cell in a nested field array row, e.g. `lines.0.label`. */
2
- export function nestedFieldPath(
3
- field: string,
4
- index: number,
5
- source: string,
6
- ): string {
7
- return `${field}.${index}.${source}`;
8
- }
1
+ /** RHF path for one cell in a nested field array row, e.g. `lines.0.label`. */
2
+ export function nestedFieldPath(
3
+ field: string,
4
+ index: number,
5
+ source: string,
6
+ ): string {
7
+ return `${field}.${index}.${source}`;
8
+ }
@@ -1,17 +1,22 @@
1
- import { hasUploadValues } from "./hasUploadValues";
2
- import { toFormData, type ToFormDataOptions } from "./toFormData";
3
-
4
- export type PrepareFormSubmitBodyOptions = ToFormDataOptions;
5
-
6
- /**
7
- * When the payload contains any `File`/`Blob`, return `FormData`; otherwise the plain object.
8
- */
9
- export function prepareFormSubmitBody(
10
- payload: Record<string, unknown>,
11
- options?: PrepareFormSubmitBodyOptions,
12
- ): Record<string, unknown> | FormData {
13
- if (hasUploadValues(payload)) {
14
- return toFormData(payload, options);
15
- }
16
- return payload;
17
- }
1
+ import { hasUploadValues } from "./hasUploadValues";
2
+ import { toFormData, type ToFormDataOptions } from "./toFormData";
3
+ import { stripExistingUploadReferencesFromPayload } from "./uploadReferenceUtils";
4
+
5
+ export type PrepareFormSubmitBodyOptions = ToFormDataOptions;
6
+
7
+ /**
8
+ * When the payload contains any `File`/`Blob`, return `FormData`; otherwise the plain object.
9
+ * Unchanged upload URL references are omitted so edit saves do not re-post existing files.
10
+ */
11
+ export function prepareFormSubmitBody(
12
+ payload: Record<string, unknown>,
13
+ options?: PrepareFormSubmitBodyOptions,
14
+ ): Record<string, unknown> | FormData {
15
+ const skipExistingUploadUrls = options?.skipExistingUploadUrls ?? true;
16
+
17
+ if (hasUploadValues(payload)) {
18
+ return toFormData(payload, options);
19
+ }
20
+
21
+ return stripExistingUploadReferencesFromPayload(payload, skipExistingUploadUrls);
22
+ }
@@ -0,0 +1,7 @@
1
+ import { Spin } from "antd";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** Hide Ant Design Select "No data" while options are loading. */
5
+ export function referenceSelectNotFoundContent(loading: boolean): ReactNode {
6
+ return loading ? <Spin size="small" /> : undefined;
7
+ }
@@ -1,18 +1,18 @@
1
- /** Write a value into an object using dot notation, e.g. `invoiceLine.product`. */
2
- export function setFormValue(
3
- record: Record<string, unknown>,
4
- path: string,
5
- value: unknown,
6
- ): void {
7
- const parts = path.split(".");
8
- let cur: Record<string, unknown> = record;
9
- for (let i = 0; i < parts.length - 1; i++) {
10
- const part = parts[i];
11
- const next = cur[part];
12
- if (next == null || typeof next !== "object" || Array.isArray(next)) {
13
- cur[part] = {};
14
- }
15
- cur = cur[part] as Record<string, unknown>;
16
- }
17
- cur[parts[parts.length - 1]!] = value;
18
- }
1
+ /** Write a value into an object using dot notation, e.g. `invoiceLine.product`. */
2
+ export function setFormValue(
3
+ record: Record<string, unknown>,
4
+ path: string,
5
+ value: unknown,
6
+ ): void {
7
+ const parts = path.split(".");
8
+ let cur: Record<string, unknown> = record;
9
+ for (let i = 0; i < parts.length - 1; i++) {
10
+ const part = parts[i];
11
+ const next = cur[part];
12
+ if (next == null || typeof next !== "object" || Array.isArray(next)) {
13
+ cur[part] = {};
14
+ }
15
+ cur = cur[part] as Record<string, unknown>;
16
+ }
17
+ cur[parts[parts.length - 1]!] = value;
18
+ }
@@ -1,37 +1,37 @@
1
- import type { SortOrder, SortSpec } from "../../data/dataProviderTypes";
2
-
3
- /** Parse `sort` query (Django-style: `sku,-price`; legacy: `sku:asc,price:desc`). */
4
- export function parseSortParam(raw: string | null): SortSpec[] {
5
- if (!raw) return [];
6
- const specs: SortSpec[] = [];
7
- for (const part of raw.split(",").map((p) => p.trim()).filter(Boolean)) {
8
- if (part.includes(":")) {
9
- const [field, orderRaw] = part.split(":");
10
- if (!field) continue;
11
- const order: SortOrder =
12
- orderRaw?.toLowerCase() === "desc" ? "DESC" : "ASC";
13
- specs.push({ field, order });
14
- continue;
15
- }
16
- if (part.startsWith("-") && part.length > 1) {
17
- specs.push({ field: part.slice(1), order: "DESC" });
18
- } else {
19
- specs.push({ field: part, order: "ASC" });
20
- }
21
- }
22
- return specs;
23
- }
24
-
25
- /** Serialize sort specs to Django-style `sku,-price`. */
26
- export function serializeSort(specs: SortSpec[]): string | null {
27
- if (specs.length === 0) return null;
28
- return specs
29
- .map((s) => (s.order === "DESC" ? `-${s.field}` : s.field))
30
- .join(",");
31
- }
32
-
33
- export function sortPrioritiesFromSpecs(
34
- specs: SortSpec[],
35
- ): Map<string, number> {
36
- return new Map(specs.map((s, i) => [s.field, i + 1]));
37
- }
1
+ import type { SortOrder, SortSpec } from "../../data/dataProviderTypes";
2
+
3
+ /** Parse `sort` query (Django-style: `sku,-price`; legacy: `sku:asc,price:desc`). */
4
+ export function parseSortParam(raw: string | null): SortSpec[] {
5
+ if (!raw) return [];
6
+ const specs: SortSpec[] = [];
7
+ for (const part of raw.split(",").map((p) => p.trim()).filter(Boolean)) {
8
+ if (part.includes(":")) {
9
+ const [field, orderRaw] = part.split(":");
10
+ if (!field) continue;
11
+ const order: SortOrder =
12
+ orderRaw?.toLowerCase() === "desc" ? "DESC" : "ASC";
13
+ specs.push({ field, order });
14
+ continue;
15
+ }
16
+ if (part.startsWith("-") && part.length > 1) {
17
+ specs.push({ field: part.slice(1), order: "DESC" });
18
+ } else {
19
+ specs.push({ field: part, order: "ASC" });
20
+ }
21
+ }
22
+ return specs;
23
+ }
24
+
25
+ /** Serialize sort specs to Django-style `sku,-price`. */
26
+ export function serializeSort(specs: SortSpec[]): string | null {
27
+ if (specs.length === 0) return null;
28
+ return specs
29
+ .map((s) => (s.order === "DESC" ? `-${s.field}` : s.field))
30
+ .join(",");
31
+ }
32
+
33
+ export function sortPrioritiesFromSpecs(
34
+ specs: SortSpec[],
35
+ ): Map<string, number> {
36
+ return new Map(specs.map((s, i) => [s.field, i + 1]));
37
+ }
@@ -1,81 +1,79 @@
1
- export type ToFormDataOptions = {
2
- /**
3
- * Omit unchanged upload URL strings (`http://` / `https://`) from the body.
4
- * Default `true` — edit forms keep existing files without re-sending the URL.
5
- */
6
- skipExistingUploadUrls?: boolean;
7
- };
8
-
9
- function isExistingUploadUrl(value: string): boolean {
10
- return /^https?:\/\//i.test(value);
11
- }
12
-
13
- function appendFormDataValue(
14
- formData: FormData,
15
- key: string,
16
- value: unknown,
17
- options: Required<ToFormDataOptions>,
18
- ): void {
19
- if (value === undefined) return;
20
-
21
- if (value === null) {
22
- formData.append(key, "");
23
- return;
24
- }
25
-
26
- if (value instanceof Blob) {
27
- formData.append(key, value);
28
- return;
29
- }
30
-
31
- if (typeof value === "boolean" || typeof value === "number") {
32
- formData.append(key, String(value));
33
- return;
34
- }
35
-
36
- if (typeof value === "string") {
37
- if (options.skipExistingUploadUrls && isExistingUploadUrl(value)) {
38
- return;
39
- }
40
- formData.append(key, value);
41
- return;
42
- }
43
-
44
- if (Array.isArray(value)) {
45
- value.forEach((item, index) => {
46
- appendFormDataValue(formData, `${key}[${index}]`, item, options);
47
- });
48
- return;
49
- }
50
-
51
- if (typeof value === "object") {
52
- for (const [childKey, childValue] of Object.entries(
53
- value as Record<string, unknown>,
54
- )) {
55
- appendFormDataValue(formData, `${key}[${childKey}]`, childValue, options);
56
- }
57
- return;
58
- }
59
-
60
- formData.append(key, String(value));
61
- }
62
-
63
- /**
64
- * Flatten a save payload into `FormData` using bracket notation for nested fields
65
- * and arrays (e.g. `lines[0][label]`, `address[city]`).
66
- */
67
- export function toFormData(
68
- data: Record<string, unknown>,
69
- options?: ToFormDataOptions,
70
- ): FormData {
71
- const resolved: Required<ToFormDataOptions> = {
72
- skipExistingUploadUrls: options?.skipExistingUploadUrls ?? true,
73
- };
74
- const formData = new FormData();
75
-
76
- for (const [key, value] of Object.entries(data)) {
77
- appendFormDataValue(formData, key, value, resolved);
78
- }
79
-
80
- return formData;
81
- }
1
+ import { isExistingUploadReference } from "./uploadReferenceUtils";
2
+
3
+ export type ToFormDataOptions = {
4
+ /**
5
+ * Omit unchanged upload URL strings (`http://` / `https://` / `/media/`) from the body.
6
+ * Default `true` — edit forms keep existing files without re-sending the URL.
7
+ */
8
+ skipExistingUploadUrls?: boolean;
9
+ };
10
+
11
+ function appendFormDataValue(
12
+ formData: FormData,
13
+ key: string,
14
+ value: unknown,
15
+ options: Required<ToFormDataOptions>,
16
+ ): void {
17
+ if (value === undefined) return;
18
+
19
+ if (value === null) {
20
+ formData.append(key, "");
21
+ return;
22
+ }
23
+
24
+ if (value instanceof Blob) {
25
+ formData.append(key, value);
26
+ return;
27
+ }
28
+
29
+ if (typeof value === "boolean" || typeof value === "number") {
30
+ formData.append(key, String(value));
31
+ return;
32
+ }
33
+
34
+ if (typeof value === "string") {
35
+ if (options.skipExistingUploadUrls && isExistingUploadReference(value)) {
36
+ return;
37
+ }
38
+ formData.append(key, value);
39
+ return;
40
+ }
41
+
42
+ if (Array.isArray(value)) {
43
+ value.forEach((item, index) => {
44
+ appendFormDataValue(formData, `${key}[${index}]`, item, options);
45
+ });
46
+ return;
47
+ }
48
+
49
+ if (typeof value === "object") {
50
+ for (const [childKey, childValue] of Object.entries(
51
+ value as Record<string, unknown>,
52
+ )) {
53
+ appendFormDataValue(formData, `${key}[${childKey}]`, childValue, options);
54
+ }
55
+ return;
56
+ }
57
+
58
+ formData.append(key, String(value));
59
+ }
60
+
61
+ /**
62
+ * Flatten a save payload into `FormData` using bracket notation for nested fields
63
+ * and arrays (e.g. `lines[0][label]`, `address[city]`).
64
+ */
65
+ export function toFormData(
66
+ data: Record<string, unknown>,
67
+ options?: ToFormDataOptions,
68
+ ): FormData {
69
+ const resolved: Required<ToFormDataOptions> = {
70
+ skipExistingUploadUrls: options?.skipExistingUploadUrls ?? true,
71
+ };
72
+ const formData = new FormData();
73
+
74
+ for (const [key, value] of Object.entries(data)) {
75
+ appendFormDataValue(formData, key, value, resolved);
76
+ }
77
+
78
+ return formData;
79
+ }
@@ -0,0 +1,44 @@
1
+ /** Existing server-side upload reference — omit on save unless replaced with a new File. */
2
+ export function isExistingUploadReference(value: string): boolean {
3
+ return /^https?:\/\//i.test(value) || value.startsWith("/media/");
4
+ }
5
+
6
+ export function stripExistingUploadReferences(
7
+ value: unknown,
8
+ skipExistingUploadUrls: boolean,
9
+ ): unknown {
10
+ if (!skipExistingUploadUrls) return value;
11
+
12
+ if (typeof value === "string") {
13
+ return isExistingUploadReference(value) ? undefined : value;
14
+ }
15
+
16
+ if (Array.isArray(value)) {
17
+ return value
18
+ .map((item) => stripExistingUploadReferences(item, skipExistingUploadUrls))
19
+ .filter((item) => item !== undefined);
20
+ }
21
+
22
+ if (value && typeof value === "object" && !(value instanceof Blob)) {
23
+ const result: Record<string, unknown> = {};
24
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
25
+ const stripped = stripExistingUploadReferences(child, skipExistingUploadUrls);
26
+ if (stripped !== undefined) {
27
+ result[key] = stripped;
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+
33
+ return value;
34
+ }
35
+
36
+ export function stripExistingUploadReferencesFromPayload(
37
+ payload: Record<string, unknown>,
38
+ skipExistingUploadUrls = true,
39
+ ): Record<string, unknown> {
40
+ return stripExistingUploadReferences(payload, skipExistingUploadUrls) as Record<
41
+ string,
42
+ unknown
43
+ >;
44
+ }
@@ -1,17 +1,17 @@
1
- import { useEffect, type DependencyList } from "react";
2
-
3
- /**
4
- * Runs an async effect with an `AbortSignal` that aborts on cleanup.
5
- * Use with `params.signal` on DataProvider reads and `isAbortError` in catch blocks.
6
- */
7
- export function useAbortableEffect(
8
- effect: (signal: AbortSignal) => void | Promise<void>,
9
- deps: DependencyList,
10
- ): void {
11
- useEffect(() => {
12
- const controller = new AbortController();
13
- void effect(controller.signal);
14
- return () => controller.abort();
15
- // eslint-disable-next-line react-hooks/exhaustive-deps
16
- }, deps);
17
- }
1
+ import { useEffect, type DependencyList } from "react";
2
+
3
+ /**
4
+ * Runs an async effect with an `AbortSignal` that aborts on cleanup.
5
+ * Use with `params.signal` on DataProvider reads and `isAbortError` in catch blocks.
6
+ */
7
+ export function useAbortableEffect(
8
+ effect: (signal: AbortSignal) => void | Promise<void>,
9
+ deps: DependencyList,
10
+ ): void {
11
+ useEffect(() => {
12
+ const controller = new AbortController();
13
+ void effect(controller.signal);
14
+ return () => controller.abort();
15
+ // eslint-disable-next-line react-hooks/exhaustive-deps
16
+ }, deps);
17
+ }