ding-react-admin 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +4 -10
  2. package/dist/index.js +1071 -803
  3. package/dist/src/crud/InlineFormSet.d.ts +2 -17
  4. package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
  5. package/dist/src/crud/ResourceForm.d.ts +4 -9
  6. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  7. package/dist/src/crud/ResourceList.d.ts.map +1 -1
  8. package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
  9. package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
  10. package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
  11. package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
  12. package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
  13. package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
  14. package/dist/src/crud/fields/FileField.d.ts +12 -0
  15. package/dist/src/crud/fields/FileField.d.ts.map +1 -0
  16. package/dist/src/crud/fields/ImageField.d.ts +14 -0
  17. package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
  18. package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
  19. package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
  20. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
  21. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
  22. package/dist/src/crud/index.d.ts +22 -9
  23. package/dist/src/crud/index.d.ts.map +1 -1
  24. package/dist/src/crud/types.d.ts +9 -7
  25. package/dist/src/crud/types.d.ts.map +1 -1
  26. package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
  27. package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
  28. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
  29. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
  30. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
  31. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
  32. package/dist/src/crud/utils/formErrors.d.ts +1 -1
  33. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  34. package/dist/src/crud/utils/getFormValue.d.ts +3 -0
  35. package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
  36. package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
  37. package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
  38. package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
  39. package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
  40. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +7 -0
  41. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
  42. package/dist/src/crud/utils/setFormValue.d.ts +3 -0
  43. package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
  44. package/dist/src/crud/utils/toFormData.d.ts +13 -0
  45. package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
  46. package/dist/src/crud/utils/useFormRecord.d.ts +42 -0
  47. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
  48. package/dist/src/data/createRestResourceHandlers.d.ts +5 -5
  49. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
  50. package/dist/src/data/dataProviderTypes.d.ts +6 -5
  51. package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
  52. package/dist/src/data/parseFormErrorHelpers.d.ts +44 -6
  53. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  54. package/dist/src/data/resourceHandlers.d.ts +1 -1
  55. package/dist/src/data/resourceHandlers.d.ts.map +1 -1
  56. package/dist/src/index.d.ts +4 -4
  57. package/dist/src/index.d.ts.map +1 -1
  58. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/crud/InlineFormSet.tsx +28 -85
  61. package/src/crud/ResourceForm.tsx +60 -66
  62. package/src/crud/ResourceFormModal.tsx +25 -25
  63. package/src/crud/ResourceList.tsx +1 -0
  64. package/src/crud/columns/ImageColumn.tsx +58 -0
  65. package/src/crud/columns/TextColumn.tsx +2 -2
  66. package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
  67. package/src/crud/context/PayloadFieldsContext.tsx +62 -0
  68. package/src/crud/fields/FieldWrapper.tsx +87 -87
  69. package/src/crud/fields/FileField.tsx +123 -0
  70. package/src/crud/fields/ImageField.tsx +120 -0
  71. package/src/crud/fields/uploadFieldUtils.ts +21 -0
  72. package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
  73. package/src/crud/index.ts +23 -19
  74. package/src/crud/types.ts +9 -7
  75. package/src/crud/utils/buildFormPayload.ts +24 -0
  76. package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
  77. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
  78. package/src/crud/utils/formErrors.ts +42 -38
  79. package/src/crud/utils/{getByPath.ts → getFormValue.ts} +2 -2
  80. package/src/crud/utils/hasUploadValues.ts +9 -0
  81. package/src/crud/utils/nestedFieldPath.ts +8 -0
  82. package/src/crud/utils/prepareFormSubmitBody.ts +17 -0
  83. package/src/crud/utils/{setByPath.ts → setFormValue.ts} +2 -2
  84. package/src/crud/utils/toFormData.ts +81 -0
  85. package/src/crud/utils/useFormRecord.ts +156 -0
  86. package/src/data/createRestResourceHandlers.ts +19 -13
  87. package/src/data/dataProviderTypes.ts +7 -5
  88. package/src/data/dataUtils.test.ts +118 -6
  89. package/src/data/parseFormErrorHelpers.test.ts +138 -84
  90. package/src/data/parseFormErrorHelpers.ts +311 -224
  91. package/src/data/resourceHandlers.ts +1 -1
  92. package/src/index.ts +23 -11
  93. package/src/layouts/AdminLayout.tsx +2 -1
  94. package/dist/src/crud/context/SubmitFieldsContext.d.ts +0 -18
  95. package/dist/src/crud/context/SubmitFieldsContext.d.ts.map +0 -1
  96. package/dist/src/crud/utils/getByPath.d.ts +0 -3
  97. package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
  98. package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
  99. package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
  100. package/dist/src/crud/utils/inlineFieldName.d.ts +0 -3
  101. package/dist/src/crud/utils/inlineFieldName.d.ts.map +0 -1
  102. package/dist/src/crud/utils/pickBySources.d.ts +0 -6
  103. package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
  104. package/dist/src/crud/utils/setByPath.d.ts +0 -3
  105. package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
  106. package/dist/src/crud/utils/useResourceFormData.d.ts +0 -47
  107. package/dist/src/crud/utils/useResourceFormData.d.ts.map +0 -1
  108. package/src/crud/context/SubmitFieldsContext.tsx +0 -62
  109. package/src/crud/utils/inlineArrayName.ts +0 -4
  110. package/src/crud/utils/inlineFieldName.ts +0 -8
  111. package/src/crud/utils/pickBySources.ts +0 -24
  112. package/src/crud/utils/useResourceFormData.ts +0 -231
@@ -8,9 +8,9 @@ import {
8
8
  import { useCallback, useRef, useState, type ReactNode } from "react";
9
9
  import { useDataProvider } from "../context/DataProvider";
10
10
  import { FormMetaProvider } from "./context/FormContext";
11
- import { SubmitFieldsProvider } from "./context/SubmitFieldsContext";
12
- import { pickBySources } from "./utils/pickBySources";
13
- import { parseAndApplyFormErrors } from "./utils/formErrors";
11
+ import { PayloadFieldsProvider } from "./context/PayloadFieldsContext";
12
+ import { buildResourceFormSubmitBody } from "./utils/buildResourceFormSubmitBody";
13
+ import { applyApiErrorsToForm } from "./utils/formErrors";
14
14
  import { useAbortableEffect } from "./utils/useAbortableEffect";
15
15
  import { isAbortError } from "../data/abortError";
16
16
 
@@ -36,7 +36,7 @@ export function ResourceFormModal({
36
36
  const [loading, setLoading] = useState(!isNew);
37
37
 
38
38
  const form = useForm<FieldValues>();
39
- const submitFieldsRef = useRef(new Set<string>());
39
+ const payloadFieldsRef = useRef(new Set<string>());
40
40
 
41
41
  const load = useCallback(
42
42
  async (signal?: AbortSignal) => {
@@ -71,20 +71,20 @@ export function ResourceFormModal({
71
71
 
72
72
  async function onSubmit(values: FieldValues) {
73
73
  try {
74
- const payload = pickBySources(
74
+ const body = buildResourceFormSubmitBody(
75
75
  values as Record<string, unknown>,
76
- Array.from(submitFieldsRef.current),
76
+ Array.from(payloadFieldsRef.current),
77
77
  );
78
78
  if (isNew) {
79
- await dp.create(resource, payload);
79
+ await dp.create(resource, body);
80
80
  message.success("Created");
81
81
  } else if (editId) {
82
- await dp.update(resource, { id: editId, data: payload });
82
+ await dp.update(resource, { id: editId, data: body });
83
83
  message.success("Updated");
84
84
  }
85
85
  onClose();
86
86
  } catch (e) {
87
- const handled = parseAndApplyFormErrors(dp, form, message, e, {
87
+ const handled = await applyApiErrorsToForm(dp, form, message, e, {
88
88
  resource,
89
89
  mutation: isNew ? "create" : "update",
90
90
  });
@@ -110,22 +110,22 @@ export function ResourceFormModal({
110
110
  <Spin />
111
111
  ) : (
112
112
  <FormMetaProvider resource={resource} isNew={isNew}>
113
- <SubmitFieldsProvider fieldsRef={submitFieldsRef}>
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
- </SubmitFieldsProvider>
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
129
  </FormMetaProvider>
130
130
  )}
131
131
  </Modal>
@@ -526,6 +526,7 @@ function ResourceListTable<T extends Record<string, unknown>>({
526
526
  loading={loading}
527
527
  columns={tableColumns}
528
528
  dataSource={data}
529
+ scroll={{ x: "max-content" }}
529
530
  rowSelection={
530
531
  showBulkActions
531
532
  ? {
@@ -0,0 +1,58 @@
1
+ import { useMemo, type CSSProperties } from "react";
2
+ import type { BaseSourceProps } from "../types";
3
+ import { useRegisterColumn } from "../context/ListContext";
4
+
5
+ export type ImageColumnProps = BaseSourceProps & {
6
+ sortable?: boolean;
7
+ /** Thumbnail width in pixels. */
8
+ width?: number;
9
+ /** Thumbnail height in pixels. */
10
+ height?: number;
11
+ objectFit?: CSSProperties["objectFit"];
12
+ borderRadius?: number;
13
+ alt?: string;
14
+ };
15
+
16
+ export function ImageColumn({
17
+ source,
18
+ label,
19
+ sortable = false,
20
+ width = 40,
21
+ height = 40,
22
+ objectFit = "cover",
23
+ borderRadius = 4,
24
+ alt = "",
25
+ }: ImageColumnProps) {
26
+ const def = useMemo(
27
+ () => ({
28
+ key: source,
29
+ source,
30
+ label,
31
+ sortable,
32
+ buildColumn: () => ({
33
+ title: label ?? source,
34
+ dataIndex: source,
35
+ key: source,
36
+ sorter: sortable ? true : undefined,
37
+ render: (value: unknown) => {
38
+ if (value == null || value === "") return null;
39
+ return (
40
+ <img
41
+ src={String(value)}
42
+ alt={alt}
43
+ style={{
44
+ width,
45
+ height,
46
+ objectFit,
47
+ borderRadius,
48
+ }}
49
+ />
50
+ );
51
+ },
52
+ }),
53
+ }),
54
+ [source, label, sortable, width, height, objectFit, borderRadius, alt],
55
+ );
56
+ useRegisterColumn(def);
57
+ return null;
58
+ }
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import type { BaseSourceProps } from "../types";
3
- import { getByPath } from "../utils/getByPath";
3
+ import { getFormValue } from "../utils/getFormValue";
4
4
  import { useRegisterColumn } from "../context/ListContext";
5
5
 
6
6
  export type TextColumnProps = BaseSourceProps & {
@@ -33,6 +33,6 @@ export function renderDisplayValue(
33
33
  display?: string | ((record: Record<string, unknown>) => unknown),
34
34
  ): unknown {
35
35
  if (typeof display === "function") return display(record);
36
- if (display) return getByPath(record, display);
36
+ if (display) return getFormValue(record, display);
37
37
  return record[source];
38
38
  }
@@ -0,0 +1,61 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ type RefObject,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ export type InlineFieldRegistration = {
10
+ field: string;
11
+ sources: string[];
12
+ payloadKey?: string;
13
+ transformRows?: (rows: Record<string, unknown>[]) => unknown;
14
+ };
15
+
16
+ const InlineFieldsRegistryContext =
17
+ createContext<RefObject<Map<string, InlineFieldRegistration>> | null>(
18
+ null,
19
+ );
20
+
21
+ export function InlineFieldsRegistryProvider({
22
+ children,
23
+ registryRef,
24
+ }: {
25
+ children: ReactNode;
26
+ registryRef: RefObject<Map<string, InlineFieldRegistration>>;
27
+ }) {
28
+ return (
29
+ <InlineFieldsRegistryContext.Provider value={registryRef}>
30
+ {children}
31
+ </InlineFieldsRegistryContext.Provider>
32
+ );
33
+ }
34
+
35
+ export function useInlineFieldsRegistryOptional() {
36
+ return useContext(InlineFieldsRegistryContext);
37
+ }
38
+
39
+ /** Register an inline field array (`useFieldArray` name) for save payload cleaning. */
40
+ export function useRegisterInlineField(
41
+ field: string,
42
+ sources: string[],
43
+ payloadKey?: string,
44
+ transformRows?: (rows: Record<string, unknown>[]) => unknown,
45
+ enabled = true,
46
+ ) {
47
+ const registryRef = useInlineFieldsRegistryOptional();
48
+
49
+ useEffect(() => {
50
+ if (!enabled || !registryRef) return;
51
+ registryRef.current.set(field, {
52
+ field,
53
+ sources,
54
+ payloadKey,
55
+ transformRows,
56
+ });
57
+ return () => {
58
+ registryRef.current.delete(field);
59
+ };
60
+ }, [registryRef, field, sources, payloadKey, transformRows, enabled]);
61
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ type RefObject,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { useFormSectionSourcesOptional } from "./FormSectionContext";
9
+
10
+ const PayloadFieldsContext =
11
+ createContext<RefObject<Set<string>> | null>(null);
12
+
13
+ /** Tracks which field paths are included in the save payload. */
14
+ export function PayloadFieldsProvider({
15
+ children,
16
+ fieldsRef,
17
+ }: {
18
+ children: ReactNode;
19
+ fieldsRef: RefObject<Set<string>>;
20
+ }) {
21
+ return (
22
+ <PayloadFieldsContext.Provider value={fieldsRef}>
23
+ {children}
24
+ </PayloadFieldsContext.Provider>
25
+ );
26
+ }
27
+
28
+ export function usePayloadFieldsRefOptional() {
29
+ return useContext(PayloadFieldsContext);
30
+ }
31
+
32
+ /**
33
+ * Register a field path for the save payload (`buildFormPayload`).
34
+ * Top-level fields use `source`; inline arrays register their `field` name.
35
+ */
36
+ export function useRegisterPayloadField(fieldPath: string, enabled = true) {
37
+ const fieldsRef = usePayloadFieldsRefOptional();
38
+
39
+ useEffect(() => {
40
+ if (!enabled || !fieldsRef) return;
41
+ fieldsRef.current.add(fieldPath);
42
+ return () => {
43
+ fieldsRef.current.delete(fieldPath);
44
+ };
45
+ }, [fieldsRef, fieldPath, enabled]);
46
+ }
47
+
48
+ /**
49
+ * Register a field for tab/step error highlighting.
50
+ * Top-level fields only — no-op outside FormTabs / FormSteps.
51
+ */
52
+ export function useRegisterSectionField(source: string, enabled = true) {
53
+ const sectionRef = useFormSectionSourcesOptional();
54
+
55
+ useEffect(() => {
56
+ if (!enabled || !sectionRef) return;
57
+ sectionRef.current.add(source);
58
+ return () => {
59
+ sectionRef.current.delete(source);
60
+ };
61
+ }, [sectionRef, source, enabled]);
62
+ }
@@ -1,87 +1,87 @@
1
- import { Form } from "antd";
2
- import { Controller, useFormContext, type FieldValues } from "react-hook-form";
3
- import type { ReactNode } from "react";
4
- import type { FieldRules } from "../types";
5
- import { useFormMetaOptional } from "../context/FormContext";
6
- import {
7
- useSectionField,
8
- useSubmitField,
9
- } from "../context/SubmitFieldsContext";
10
-
11
- export type FieldWrapperProps = {
12
- /** Logical field name — used for submit payload tracking on top-level fields. */
13
- source: string;
14
- /** Full RHF path. Defaults to `source`. Inline cells pass e.g. `lines.0.label`. */
15
- name?: string;
16
- label?: string;
17
- required?: boolean;
18
- rules?: FieldRules;
19
- /** Tabular inline cells: column header replaces the label. */
20
- hideLabel?: boolean;
21
- children: (props: {
22
- value: unknown;
23
- onChange: (value: unknown) => void;
24
- onBlur: () => void;
25
- disabled?: boolean;
26
- name: string;
27
- }) => ReactNode;
28
- };
29
-
30
- export function FieldWrapper({
31
- source,
32
- name,
33
- label,
34
- required,
35
- rules,
36
- hideLabel,
37
- children,
38
- }: FieldWrapperProps) {
39
- const fieldName = name ?? source;
40
- const isTopLevel = !fieldName.includes(".");
41
-
42
- // react-hook-form: connect this field to the form from <ResourceForm>'s FormProvider.
43
- // `control` is passed to <Controller> below so value, onChange, and validation work.
44
- const { control } = useFormContext<FieldValues>();
45
-
46
- // ding-react-admin: optional form-wide flags from <FormMetaProvider> (inside ResourceForm).
47
- // Used here for `disabled` on inputs (e.g. read-only view mode). Returns null outside a form page.
48
- const meta = useFormMetaOptional();
49
- const fieldLabel = hideLabel ? undefined : (label ?? source);
50
- const requiredMessage = label ?? source;
51
-
52
- // ding-react-admin: remember this `source` so Save sends only fields you rendered in JSX
53
- // (see pickBySources in ResourceForm). Skipped for inline row cells nested `name` paths.
54
- useSubmitField(source, isTopLevel);
55
-
56
- // ding-react-admin: group this field with the current FormTab / FormStep for error highlighting
57
- // and "jump to first invalid tab" on Save. No-op outside tabs/steps.
58
- useSectionField(source, isTopLevel);
59
-
60
- return (
61
- <Controller
62
- name={fieldName}
63
- control={control}
64
- rules={{
65
- required: required ? `${requiredMessage} is required` : false,
66
- ...rules,
67
- }}
68
- render={({ field, fieldState }) => (
69
- <Form.Item
70
- label={fieldLabel}
71
- validateStatus={fieldState.error ? "error" : undefined}
72
- help={fieldState.error?.message}
73
- required={required && !hideLabel}
74
- style={hideLabel ? { marginBottom: 0 } : undefined}
75
- >
76
- {children({
77
- value: field.value,
78
- onChange: field.onChange,
79
- onBlur: field.onBlur,
80
- disabled: meta?.disabled,
81
- name: fieldName,
82
- })}
83
- </Form.Item>
84
- )}
85
- />
86
- );
87
- }
1
+ import { Form } from "antd";
2
+ import { Controller, useFormContext, type FieldValues } from "react-hook-form";
3
+ import type { ReactNode } from "react";
4
+ import type { FieldRules } from "../types";
5
+ import { useFormMetaOptional } from "../context/FormContext";
6
+ import {
7
+ useRegisterPayloadField,
8
+ useRegisterSectionField,
9
+ } from "../context/PayloadFieldsContext";
10
+
11
+ export type FieldWrapperProps = {
12
+ /** Logical field name — used for submit payload tracking on top-level fields. */
13
+ source: string;
14
+ /** Full RHF path. Defaults to `source`. Inline cells pass e.g. `lines.0.label`. */
15
+ name?: string;
16
+ label?: string;
17
+ required?: boolean;
18
+ rules?: FieldRules;
19
+ /** Tabular inline cells: column header replaces the label. */
20
+ hideLabel?: boolean;
21
+ children: (props: {
22
+ value: unknown;
23
+ onChange: (value: unknown) => void;
24
+ onBlur: () => void;
25
+ disabled?: boolean;
26
+ name: string;
27
+ }) => ReactNode;
28
+ };
29
+
30
+ export function FieldWrapper({
31
+ source,
32
+ name,
33
+ label,
34
+ required,
35
+ rules,
36
+ hideLabel,
37
+ children,
38
+ }: FieldWrapperProps) {
39
+ const fieldName = name ?? source;
40
+ const isTopLevel = !fieldName.includes(".");
41
+
42
+ // react-hook-form: connect this field to the form from <ResourceForm>'s FormProvider.
43
+ // `control` is passed to <Controller> below so value, onChange, and validation work.
44
+ const { control } = useFormContext<FieldValues>();
45
+
46
+ // ding-react-admin: optional form-wide flags from <FormMetaProvider> (inside ResourceForm).
47
+ // Used here for `disabled` on inputs (e.g. read-only view mode). Returns null outside a form page.
48
+ const meta = useFormMetaOptional();
49
+ const fieldLabel = hideLabel ? undefined : (label ?? source);
50
+ const requiredMessage = label ?? source;
51
+
52
+ // Add this field's `source` to the Save payload (ResourceForm buildFormPayload).
53
+ // `isTopLevel` is false when `name` contains "." (e.g. inline cell `__inline_lines.0.label`)
54
+ // those values live in the inline array and are saved by saveInlineRows, not the parent PATCH.
55
+ useRegisterPayloadField(source, isTopLevel);
56
+
57
+ // Same top-level check: attach this field to the current FormTab / FormStep for error highlighting.
58
+ useRegisterSectionField(source, isTopLevel);
59
+
60
+ return (
61
+ <Controller
62
+ name={fieldName}
63
+ control={control}
64
+ rules={{
65
+ required: required ? `${requiredMessage} is required` : false,
66
+ ...rules,
67
+ }}
68
+ render={({ field, fieldState }) => (
69
+ <Form.Item
70
+ label={fieldLabel}
71
+ validateStatus={fieldState.error ? "error" : undefined}
72
+ help={fieldState.error?.message}
73
+ required={required && !hideLabel}
74
+ style={hideLabel ? { marginBottom: 0 } : undefined}
75
+ >
76
+ {children({
77
+ value: field.value,
78
+ onChange: field.onChange,
79
+ onBlur: field.onBlur,
80
+ disabled: meta?.disabled,
81
+ name: fieldName,
82
+ })}
83
+ </Form.Item>
84
+ )}
85
+ />
86
+ );
87
+ }
@@ -0,0 +1,123 @@
1
+ import { DeleteOutlined, PaperClipOutlined, UploadOutlined } from "@ant-design/icons";
2
+ import { Button, Space, Typography } from "antd";
3
+ import { useRef } from "react";
4
+ import type { BaseSourceProps, FieldRules } from "../types";
5
+ import { FieldWrapper } from "./FieldWrapper";
6
+ import {
7
+ getUploadFileName,
8
+ hasUploadValue,
9
+ type UploadFieldValue,
10
+ } from "./uploadFieldUtils";
11
+
12
+ export type FileFieldProps = BaseSourceProps & {
13
+ name?: string;
14
+ required?: boolean;
15
+ rules?: FieldRules;
16
+ hideLabel?: boolean;
17
+ /** Allow clearing an existing or newly selected file. */
18
+ clearable?: boolean;
19
+ accept?: string;
20
+ };
21
+
22
+ function FileFieldInput({
23
+ value,
24
+ onChange,
25
+ disabled,
26
+ clearable,
27
+ accept,
28
+ }: {
29
+ value: UploadFieldValue;
30
+ onChange: (value: UploadFieldValue) => void;
31
+ disabled?: boolean;
32
+ clearable?: boolean;
33
+ accept?: string;
34
+ }) {
35
+ const inputRef = useRef<HTMLInputElement>(null);
36
+ const fileName = getUploadFileName(value);
37
+ const existingUrl = typeof value === "string" && value.length > 0 ? value : undefined;
38
+ const showClear = clearable && hasUploadValue(value);
39
+
40
+ return (
41
+ <Space direction="vertical" size="middle" style={{ width: "100%" }}>
42
+ {fileName ? (
43
+ <Space>
44
+ <PaperClipOutlined />
45
+ {existingUrl ? (
46
+ <Typography.Link href={existingUrl} target="_blank" rel="noopener noreferrer">
47
+ {fileName}
48
+ </Typography.Link>
49
+ ) : (
50
+ <Typography.Text>{fileName}</Typography.Text>
51
+ )}
52
+ </Space>
53
+ ) : null}
54
+ <Space wrap>
55
+ <Button
56
+ icon={<UploadOutlined />}
57
+ disabled={disabled}
58
+ onClick={() => inputRef.current?.click()}
59
+ >
60
+ Choose file
61
+ </Button>
62
+ {showClear ? (
63
+ <Button
64
+ icon={<DeleteOutlined />}
65
+ disabled={disabled}
66
+ onClick={() => {
67
+ onChange(null);
68
+ if (inputRef.current) inputRef.current.value = "";
69
+ }}
70
+ >
71
+ Clear
72
+ </Button>
73
+ ) : null}
74
+ </Space>
75
+ <input
76
+ ref={inputRef}
77
+ type="file"
78
+ accept={accept}
79
+ disabled={disabled}
80
+ tabIndex={-1}
81
+ aria-hidden
82
+ style={{ display: "none" }}
83
+ onChange={(e) => {
84
+ const file = e.target.files?.[0];
85
+ onChange(file ?? null);
86
+ e.target.value = "";
87
+ }}
88
+ />
89
+ </Space>
90
+ );
91
+ }
92
+
93
+ export function FileField({
94
+ source,
95
+ name,
96
+ label,
97
+ required,
98
+ rules,
99
+ hideLabel,
100
+ clearable,
101
+ accept,
102
+ }: FileFieldProps) {
103
+ return (
104
+ <FieldWrapper
105
+ source={source}
106
+ name={name}
107
+ label={label}
108
+ required={required}
109
+ rules={rules}
110
+ hideLabel={hideLabel}
111
+ >
112
+ {({ value, onChange, disabled }) => (
113
+ <FileFieldInput
114
+ value={value as UploadFieldValue}
115
+ onChange={onChange}
116
+ disabled={disabled}
117
+ clearable={clearable}
118
+ accept={accept}
119
+ />
120
+ )}
121
+ </FieldWrapper>
122
+ );
123
+ }