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,106 +1,106 @@
1
- import { Button, Select, Space, Typography } from "antd";
2
- import { useCallback, useMemo, useState } from "react";
3
- import type { ResourceListBulkAction } from "./types";
4
-
5
- export type ListActionsBarProps = {
6
- selectedCount: number;
7
- total: number;
8
- allPageSelected: boolean;
9
- allMatchingSelected: boolean;
10
- onSelectAllMatching: () => void;
11
- onClearSelection: () => void;
12
- actions: ResourceListBulkAction[];
13
- onExecute: (
14
- action: ResourceListBulkAction,
15
- selectedIds: (string | number)[],
16
- ) => Promise<void>;
17
- selectedIds: (string | number)[];
18
- running?: boolean;
19
- };
20
-
21
- export function ListActionsBar({
22
- selectedCount,
23
- total,
24
- allPageSelected,
25
- allMatchingSelected,
26
- onSelectAllMatching,
27
- onClearSelection,
28
- actions,
29
- onExecute,
30
- selectedIds,
31
- running = false,
32
- }: ListActionsBarProps) {
33
- const [actionKey, setActionKey] = useState<string | undefined>();
34
-
35
- const options = useMemo(
36
- () => actions.map((a) => ({ value: a.key, label: a.label })),
37
- [actions],
38
- );
39
-
40
- const handleGo = useCallback(async () => {
41
- const action = actions.find((a) => a.key === actionKey);
42
- if (!action || selectedCount === 0) return;
43
- await onExecute(action, selectedIds);
44
- setActionKey(undefined);
45
- }, [actions, actionKey, onExecute, selectedCount, selectedIds]);
46
-
47
- const showSelectAllMatching =
48
- allPageSelected &&
49
- !allMatchingSelected &&
50
- total > selectedCount;
51
-
52
- return (
53
- <Space
54
- wrap
55
- style={{ marginBottom: 16, width: "100%" }}
56
- align="center"
57
- >
58
- <Typography.Text type="secondary">
59
- {selectedCount} of {total} selected
60
- </Typography.Text>
61
- {selectedCount > 0 ? (
62
- <Button type="link" size="small" onClick={onClearSelection} style={{ padding: 0 }}>
63
- Clear selection
64
- </Button>
65
- ) : null}
66
- {showSelectAllMatching ? (
67
- <>
68
- <Typography.Text type="secondary">·</Typography.Text>
69
- <Button
70
- type="link"
71
- size="small"
72
- onClick={onSelectAllMatching}
73
- style={{ padding: 0 }}
74
- >
75
- Select all {total} items matching filter
76
- </Button>
77
- </>
78
- ) : null}
79
- {allMatchingSelected && total > 0 ? (
80
- <>
81
- <Typography.Text type="secondary">·</Typography.Text>
82
- <Typography.Text type="success">
83
- All {total} items selected
84
- </Typography.Text>
85
- </>
86
- ) : null}
87
- <Select
88
- placeholder="Action"
89
- style={{ minWidth: 200 }}
90
- options={options}
91
- value={actionKey}
92
- onChange={setActionKey}
93
- disabled={selectedCount === 0 || running}
94
- allowClear
95
- />
96
- <Button
97
- type="primary"
98
- onClick={() => void handleGo()}
99
- disabled={!actionKey || selectedCount === 0 || running}
100
- loading={running}
101
- >
102
- Go
103
- </Button>
104
- </Space>
105
- );
106
- }
1
+ import { Button, Select, Space, Typography } from "antd";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import type { ResourceListBulkAction } from "./types";
4
+
5
+ export type ListActionsBarProps = {
6
+ selectedCount: number;
7
+ total: number;
8
+ allPageSelected: boolean;
9
+ allMatchingSelected: boolean;
10
+ onSelectAllMatching: () => void;
11
+ onClearSelection: () => void;
12
+ actions: ResourceListBulkAction[];
13
+ onExecute: (
14
+ action: ResourceListBulkAction,
15
+ selectedIds: (string | number)[],
16
+ ) => Promise<void>;
17
+ selectedIds: (string | number)[];
18
+ running?: boolean;
19
+ };
20
+
21
+ export function ListActionsBar({
22
+ selectedCount,
23
+ total,
24
+ allPageSelected,
25
+ allMatchingSelected,
26
+ onSelectAllMatching,
27
+ onClearSelection,
28
+ actions,
29
+ onExecute,
30
+ selectedIds,
31
+ running = false,
32
+ }: ListActionsBarProps) {
33
+ const [actionKey, setActionKey] = useState<string | undefined>();
34
+
35
+ const options = useMemo(
36
+ () => actions.map((a) => ({ value: a.key, label: a.label })),
37
+ [actions],
38
+ );
39
+
40
+ const handleGo = useCallback(async () => {
41
+ const action = actions.find((a) => a.key === actionKey);
42
+ if (!action || selectedCount === 0) return;
43
+ await onExecute(action, selectedIds);
44
+ setActionKey(undefined);
45
+ }, [actions, actionKey, onExecute, selectedCount, selectedIds]);
46
+
47
+ const showSelectAllMatching =
48
+ allPageSelected &&
49
+ !allMatchingSelected &&
50
+ total > selectedCount;
51
+
52
+ return (
53
+ <Space
54
+ wrap
55
+ style={{ marginBottom: 16, width: "100%" }}
56
+ align="center"
57
+ >
58
+ <Typography.Text type="secondary">
59
+ {selectedCount} of {total} selected
60
+ </Typography.Text>
61
+ {selectedCount > 0 ? (
62
+ <Button type="link" size="small" onClick={onClearSelection} style={{ padding: 0 }}>
63
+ Clear selection
64
+ </Button>
65
+ ) : null}
66
+ {showSelectAllMatching ? (
67
+ <>
68
+ <Typography.Text type="secondary">·</Typography.Text>
69
+ <Button
70
+ type="link"
71
+ size="small"
72
+ onClick={onSelectAllMatching}
73
+ style={{ padding: 0 }}
74
+ >
75
+ Select all {total} items matching filter
76
+ </Button>
77
+ </>
78
+ ) : null}
79
+ {allMatchingSelected && total > 0 ? (
80
+ <>
81
+ <Typography.Text type="secondary">·</Typography.Text>
82
+ <Typography.Text type="success">
83
+ All {total} items selected
84
+ </Typography.Text>
85
+ </>
86
+ ) : null}
87
+ <Select
88
+ placeholder="Action"
89
+ style={{ minWidth: 200 }}
90
+ options={options}
91
+ value={actionKey}
92
+ onChange={setActionKey}
93
+ disabled={selectedCount === 0 || running}
94
+ allowClear
95
+ />
96
+ <Button
97
+ type="primary"
98
+ onClick={() => void handleGo()}
99
+ disabled={!actionKey || selectedCount === 0 || running}
100
+ loading={running}
101
+ >
102
+ Go
103
+ </Button>
104
+ </Space>
105
+ );
106
+ }
@@ -1,160 +1,83 @@
1
- import { ArrowLeftOutlined } from "@ant-design/icons";
2
- import { App, Button, Card, Form, Space, Spin, Typography, theme } from "antd";
3
- import { FormProvider, useForm, type DefaultValues, type FieldValues } from "react-hook-form";
4
- import { useEffect, useRef, type ReactNode } from "react";
5
- import { Link, useNavigate, useParams } from "react-router-dom";
6
- import { useDataProvider } from "../context/DataProvider";
7
- import { usePermissions } from "../context/PermissionsProvider";
8
- import type { ResourcePermissions } from "../permissions/resourcePermissions";
9
- import { checkResourcePermission } from "../permissions/resourcePermissions";
10
- import { FormMetaProvider } from "./context/FormContext";
11
- import { PayloadFieldsProvider } from "./context/PayloadFieldsContext";
12
- import {
13
- InlineFieldsRegistryProvider,
14
- type InlineFieldRegistration,
15
- } from "./context/InlineFieldsRegistry";
16
- import { useFormRecordLoad, useFormRecordSave } from "./utils/useFormRecord";
17
-
18
- export type ResourceFormProps<T extends FieldValues> = {
19
- resource: string;
20
- title: string;
21
- listPath: string;
22
- children: ReactNode;
23
- defaultValues?: Partial<T>;
24
- onSaved?: (record: T) => void;
25
- stayOnPage?: boolean;
26
- /** Permission strings for create vs edit. Omit to allow all (demos only). */
27
- permissions?: Pick<ResourcePermissions, "add" | "change">;
28
- };
29
-
30
- /**
31
- * Create/edit page — standard react-hook-form under the hood.
32
- *
33
- * Load: one `getOne` → `form.reset(record)` (nested inline arrays included).
34
- * Save: one `create` or `update` with parent fields + nested inline rows.
35
- */
36
- export function ResourceForm<T extends FieldValues & { id?: unknown }>({
37
- resource,
38
- title,
39
- listPath,
40
- children,
41
- defaultValues,
42
- onSaved,
43
- stayOnPage,
44
- permissions,
45
- }: ResourceFormProps<T>) {
46
- const { id } = useParams();
47
- const isNew = id === "new" || !id;
48
- const dp = useDataProvider();
49
- const can = usePermissions();
50
- const navigate = useNavigate();
51
- const { message } = App.useApp();
52
- const { token } = theme.useToken();
53
- const payloadFieldsRef = useRef(new Set<string>());
54
- const inlineRegistryRef = useRef(new Map<string, InlineFieldRegistration>());
55
-
56
- const form = useForm<T>({
57
- defaultValues: defaultValues as DefaultValues<T>,
58
- });
59
-
60
- const { loading, formVersion } = useFormRecordLoad({
61
- dp,
62
- resource,
63
- id,
64
- isNew,
65
- form,
66
- message,
67
- defaultValues,
68
- });
69
-
70
- const onSubmit = useFormRecordSave({
71
- dp,
72
- resource,
73
- id,
74
- isNew,
75
- form,
76
- message,
77
- navigate,
78
- listPath,
79
- payloadFieldsRef,
80
- inlineRegistryRef,
81
- onSaved,
82
- stayOnPage,
83
- });
84
-
85
- useEffect(() => {
86
- if (!permissions) return;
87
- const slot = isNew ? "add" : "change";
88
- if (!checkResourcePermission(can, permissions, slot)) {
89
- navigate(listPath, { replace: true });
90
- }
91
- }, [permissions, isNew, can, navigate, listPath]);
92
-
93
- const canSave = permissions
94
- ? checkResourcePermission(can, permissions, isNew ? "add" : "change")
95
- : true;
96
-
97
- return (
98
- <Card
99
- title={
100
- <Space>
101
- <Link to={listPath} style={{ color: token.colorText }}>
102
- <ArrowLeftOutlined /> Back
103
- </Link>
104
- <Typography.Title level={5} style={{ margin: 0 }}>
105
- {title}
106
- </Typography.Title>
107
- </Space>
108
- }
109
- >
110
- <FormMetaProvider resource={resource} isNew={isNew}>
111
- <PayloadFieldsProvider fieldsRef={payloadFieldsRef}>
112
- <InlineFieldsRegistryProvider registryRef={inlineRegistryRef}>
113
- <div style={{ position: "relative" }}>
114
- {loading ? (
115
- <div
116
- style={{
117
- position: "absolute",
118
- inset: 0,
119
- display: "flex",
120
- alignItems: "center",
121
- justifyContent: "center",
122
- zIndex: 1,
123
- }}
124
- >
125
- <Spin />
126
- </div>
127
- ) : null}
128
- <FormProvider {...form} key={formVersion}>
129
- <Form
130
- layout="vertical"
131
- onFinish={() => void form.handleSubmit(onSubmit)()}
132
- style={{
133
- opacity: loading ? 0.4 : 1,
134
- pointerEvents: loading ? "none" : undefined,
135
- }}
136
- >
137
- {children}
138
- <Form.Item style={{ marginTop: 16 }}>
139
- <Space>
140
- <Button
141
- type="primary"
142
- htmlType="submit"
143
- disabled={loading || !canSave}
144
- >
145
- Save
146
- </Button>
147
- <Link to={listPath}>
148
- <Button disabled={loading}>Cancel</Button>
149
- </Link>
150
- </Space>
151
- </Form.Item>
152
- </Form>
153
- </FormProvider>
154
- </div>
155
- </InlineFieldsRegistryProvider>
156
- </PayloadFieldsProvider>
157
- </FormMetaProvider>
158
- </Card>
159
- );
160
- }
1
+ import { ArrowLeftOutlined } from "@ant-design/icons";
2
+ import { Card, Space, Typography, theme } from "antd";
3
+ import { type FieldValues } from "react-hook-form";
4
+ import { useEffect, type ReactNode } from "react";
5
+ import { Link, useNavigate, useParams } from "react-router-dom";
6
+ import { usePermissions } from "../context/PermissionsProvider";
7
+ import type { ResourcePermissions } from "../permissions/resourcePermissions";
8
+ import { checkResourcePermission } from "../permissions/resourcePermissions";
9
+ import { ResourceRecordForm } from "./ResourceRecordForm";
10
+
11
+ export type ResourceFormProps<T extends FieldValues> = {
12
+ resource: string;
13
+ title: string;
14
+ listPath: string;
15
+ children: ReactNode;
16
+ defaultValues?: Partial<T>;
17
+ onSaved?: (record: T) => void;
18
+ stayOnPage?: boolean;
19
+ /** Permission strings for create vs edit. Omit to allow all (demos only). */
20
+ permissions?: Pick<ResourcePermissions, "add" | "change">;
21
+ };
22
+
23
+ /**
24
+ * Create/edit page — Card chrome around {@link ResourceRecordForm}.
25
+ */
26
+ export function ResourceForm<T extends FieldValues & { id?: unknown }>({
27
+ resource,
28
+ title,
29
+ listPath,
30
+ children,
31
+ defaultValues,
32
+ onSaved,
33
+ stayOnPage,
34
+ permissions,
35
+ }: ResourceFormProps<T>) {
36
+ const { id } = useParams();
37
+ const isNew = id === "new" || !id;
38
+ const can = usePermissions();
39
+ const navigate = useNavigate();
40
+ const { token } = theme.useToken();
41
+
42
+ useEffect(() => {
43
+ if (!permissions) return;
44
+ const slot = isNew ? "add" : "change";
45
+ if (!checkResourcePermission(can, permissions, slot)) {
46
+ navigate(listPath, { replace: true });
47
+ }
48
+ }, [permissions, isNew, can, navigate, listPath]);
49
+
50
+ const canSave = permissions
51
+ ? checkResourcePermission(can, permissions, isNew ? "add" : "change")
52
+ : true;
53
+
54
+ return (
55
+ <Card
56
+ title={
57
+ <Space>
58
+ <Link to={listPath} style={{ color: token.colorText }}>
59
+ <ArrowLeftOutlined /> Back
60
+ </Link>
61
+ <Typography.Title level={5} style={{ margin: 0 }}>
62
+ {title}
63
+ </Typography.Title>
64
+ </Space>
65
+ }
66
+ >
67
+ <ResourceRecordForm<T>
68
+ resource={resource}
69
+ id={id}
70
+ defaultValues={defaultValues}
71
+ canSave={canSave}
72
+ cancelHref={listPath}
73
+ onCancel={() => navigate(listPath)}
74
+ onSuccess={(saved) => {
75
+ onSaved?.(saved);
76
+ if (!stayOnPage) navigate(listPath);
77
+ }}
78
+ >
79
+ {children}
80
+ </ResourceRecordForm>
81
+ </Card>
82
+ );
83
+ }
@@ -1,133 +1,75 @@
1
- import { App, Button, Form, Modal, Spin } from "antd";
2
- import {
3
- FormProvider,
4
- useForm,
5
- type DefaultValues,
6
- type FieldValues,
7
- } from "react-hook-form";
8
- import { useCallback, useRef, useState, type ReactNode } from "react";
9
- import { useDataProvider } from "../context/DataProvider";
10
- import { FormMetaProvider } from "./context/FormContext";
11
- import { PayloadFieldsProvider } from "./context/PayloadFieldsContext";
12
- import { buildResourceFormSubmitBody } from "./utils/buildResourceFormSubmitBody";
13
- import { applyApiErrorsToForm } from "./utils/formErrors";
14
- import { useAbortableEffect } from "./utils/useAbortableEffect";
15
- import { isAbortError } from "../data/abortError";
16
-
17
- export type ResourceFormModalProps = {
18
- resource: string;
19
- editId: string | null;
20
- onClose: () => void;
21
- children: ReactNode;
22
- title?: string;
23
- };
24
-
25
- export function ResourceFormModal({
26
- resource,
27
- editId,
28
- onClose,
29
- children,
30
- title,
31
- }: ResourceFormModalProps) {
32
- const isNew = editId === "new" || editId == null;
33
- const open = editId != null;
34
- const dp = useDataProvider();
35
- const { message } = App.useApp();
36
- const [loading, setLoading] = useState(!isNew);
37
-
38
- const form = useForm<FieldValues>();
39
- const payloadFieldsRef = useRef(new Set<string>());
40
-
41
- const load = useCallback(
42
- async (signal?: AbortSignal) => {
43
- if (isNew || !editId) {
44
- form.reset({} as DefaultValues<FieldValues>);
45
- setLoading(false);
46
- return;
47
- }
48
- setLoading(true);
49
- try {
50
- const res = await dp.getOne(resource, editId, { signal });
51
- if (signal?.aborted) return;
52
- form.reset(res.data as DefaultValues<FieldValues>);
53
- } catch (e) {
54
- if (!isAbortError(e)) {
55
- message.error(e instanceof Error ? e.message : "Load failed");
56
- }
57
- } finally {
58
- if (!signal?.aborted) setLoading(false);
59
- }
60
- },
61
- [dp, resource, editId, isNew, form, message],
62
- );
63
-
64
- useAbortableEffect(
65
- (signal) => {
66
- if (!open) return;
67
- return load(signal);
68
- },
69
- [open, load],
70
- );
71
-
72
- async function onSubmit(values: FieldValues) {
73
- try {
74
- const body = buildResourceFormSubmitBody(
75
- values as Record<string, unknown>,
76
- Array.from(payloadFieldsRef.current),
77
- );
78
- if (isNew) {
79
- await dp.create(resource, body);
80
- message.success("Created");
81
- } else if (editId) {
82
- await dp.update(resource, { id: editId, data: body });
83
- message.success("Updated");
84
- }
85
- onClose();
86
- } catch (e) {
87
- const handled = await applyApiErrorsToForm(dp, form, message, e, {
88
- resource,
89
- mutation: isNew ? "create" : "update",
90
- });
91
- if (!handled) {
92
- message.error(e instanceof Error ? e.message : "Save failed");
93
- }
94
- }
95
- }
96
-
97
- const modalTitle =
98
- title ?? (isNew ? `New ${resource}` : `Edit ${resource}`);
99
-
100
- return (
101
- <Modal
102
- open={open}
103
- title={modalTitle}
104
- onCancel={onClose}
105
- footer={null}
106
- destroyOnHidden
107
- width={560}
108
- >
109
- {loading ? (
110
- <Spin />
111
- ) : (
112
- <FormMetaProvider resource={resource} isNew={isNew}>
113
- <PayloadFieldsProvider fieldsRef={payloadFieldsRef}>
114
- <FormProvider {...form}>
115
- <Form
116
- layout="vertical"
117
- onFinish={() => void form.handleSubmit(onSubmit)()}
118
- >
119
- {children}
120
- <Form.Item style={{ marginTop: 16, marginBottom: 0 }}>
121
- <Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
122
- Save
123
- </Button>
124
- <Button onClick={onClose}>Cancel</Button>
125
- </Form.Item>
126
- </Form>
127
- </FormProvider>
128
- </PayloadFieldsProvider>
129
- </FormMetaProvider>
130
- )}
131
- </Modal>
132
- );
133
- }
1
+ import { App, Modal } from "antd";
2
+ import { type FieldValues } from "react-hook-form";
3
+ import { type ReactNode } from "react";
4
+ import { usePermissions } from "../context/PermissionsProvider";
5
+ import {
6
+ checkResourcePermission,
7
+ type ResourcePermissions,
8
+ } from "../permissions/resourcePermissions";
9
+ import { ResourceRecordForm } from "./ResourceRecordForm";
10
+
11
+ export type ResourceFormModalProps = {
12
+ resource: string;
13
+ editId: string | null;
14
+ onClose: () => void;
15
+ children: ReactNode;
16
+ title?: string;
17
+ permissions?: Pick<ResourcePermissions, "add" | "change">;
18
+ defaultValues?: Partial<FieldValues>;
19
+ width?: number;
20
+ onSuccess?: (record: FieldValues) => void;
21
+ };
22
+
23
+ export function ResourceFormModal({
24
+ resource,
25
+ editId,
26
+ onClose,
27
+ children,
28
+ title,
29
+ permissions,
30
+ defaultValues,
31
+ width = 560,
32
+ onSuccess,
33
+ }: ResourceFormModalProps) {
34
+ const isNew = editId === "new";
35
+ const open = editId != null;
36
+ const can = usePermissions();
37
+
38
+ const modalTitle =
39
+ title ?? (isNew ? `New ${resource}` : `Edit ${resource}`);
40
+
41
+ const canSave = permissions
42
+ ? checkResourcePermission(can, permissions, isNew ? "add" : "change")
43
+ : true;
44
+
45
+ return (
46
+ <Modal
47
+ open={open}
48
+ title={modalTitle}
49
+ onCancel={onClose}
50
+ footer={null}
51
+ destroyOnHidden
52
+ width={width}
53
+ maskClosable={false}
54
+ >
55
+ {/* Nested App so message/toast works when Modal is portaled to document.body. */}
56
+ <App>
57
+ <ResourceRecordForm<FieldValues>
58
+ resource={resource}
59
+ id={editId ?? undefined}
60
+ enabled={open}
61
+ loadingMode="overlay"
62
+ defaultValues={defaultValues}
63
+ canSave={canSave}
64
+ onCancel={onClose}
65
+ onSuccess={(record) => {
66
+ onSuccess?.(record);
67
+ onClose();
68
+ }}
69
+ >
70
+ {children}
71
+ </ResourceRecordForm>
72
+ </App>
73
+ </Modal>
74
+ );
75
+ }