@fogpipe/forma-react 0.17.1 → 0.18.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.
- package/README.md +82 -0
- package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
- package/dist/chunk-5K4QITFH.js +1276 -0
- package/dist/chunk-5K4QITFH.js.map +1 -0
- package/dist/defaults/index.d.ts +56 -0
- package/dist/defaults/index.js +895 -0
- package/dist/defaults/index.js.map +1 -0
- package/dist/defaults/styles/forma-defaults.css +696 -0
- package/dist/index.d.ts +7 -559
- package/dist/index.js +28 -1292
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/__tests__/defaults/components.test.tsx +818 -0
- package/src/__tests__/defaults/integration.test.tsx +494 -0
- package/src/__tests__/defaults/layout.test.tsx +298 -0
- package/src/defaults/DefaultFormRenderer.tsx +43 -0
- package/src/defaults/componentMap.ts +45 -0
- package/src/defaults/components/ArrayField.tsx +183 -0
- package/src/defaults/components/BooleanInput.tsx +32 -0
- package/src/defaults/components/ComputedDisplay.tsx +26 -0
- package/src/defaults/components/DateInput.tsx +59 -0
- package/src/defaults/components/DisplayField.tsx +15 -0
- package/src/defaults/components/FallbackField.tsx +35 -0
- package/src/defaults/components/MatrixField.tsx +98 -0
- package/src/defaults/components/MultiSelectInput.tsx +51 -0
- package/src/defaults/components/NumberInput.tsx +73 -0
- package/src/defaults/components/ObjectField.tsx +22 -0
- package/src/defaults/components/SelectInput.tsx +44 -0
- package/src/defaults/components/TextInput.tsx +48 -0
- package/src/defaults/components/TextareaInput.tsx +46 -0
- package/src/defaults/index.ts +33 -0
- package/src/defaults/layout/FieldWrapper.tsx +83 -0
- package/src/defaults/layout/FormLayout.tsx +34 -0
- package/src/defaults/layout/PageWrapper.tsx +18 -0
- package/src/defaults/layout/WizardLayout.tsx +130 -0
- package/src/defaults/styles/forma-defaults.css +696 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/// <reference types="@testing-library/jest-dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Tests for default layout components
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
7
|
+
import { describe, it, expect, vi } from "vitest";
|
|
8
|
+
import type { FieldError } from "@fogpipe/forma-core";
|
|
9
|
+
import type { FieldWrapperProps, PageWrapperProps } from "../../types.js";
|
|
10
|
+
import { FieldWrapper } from "../../defaults/layout/FieldWrapper.js";
|
|
11
|
+
import { FormLayout } from "../../defaults/layout/FormLayout.js";
|
|
12
|
+
import { PageWrapper } from "../../defaults/layout/PageWrapper.js";
|
|
13
|
+
import { FormaContext } from "../../context.js";
|
|
14
|
+
import type { UseFormaReturn } from "../../useForma.js";
|
|
15
|
+
|
|
16
|
+
function mockFormaContext(
|
|
17
|
+
overrides: Partial<UseFormaReturn> = {},
|
|
18
|
+
): UseFormaReturn {
|
|
19
|
+
return {
|
|
20
|
+
data: {},
|
|
21
|
+
computed: {},
|
|
22
|
+
visibility: {},
|
|
23
|
+
required: {},
|
|
24
|
+
enabled: {},
|
|
25
|
+
readonly: {},
|
|
26
|
+
optionsVisibility: {},
|
|
27
|
+
touched: {},
|
|
28
|
+
errors: [],
|
|
29
|
+
isValid: true,
|
|
30
|
+
isSubmitting: false,
|
|
31
|
+
isSubmitted: false,
|
|
32
|
+
isDirty: false,
|
|
33
|
+
spec: {} as UseFormaReturn["spec"],
|
|
34
|
+
wizard: null,
|
|
35
|
+
setFieldValue: vi.fn(),
|
|
36
|
+
setFieldTouched: vi.fn(),
|
|
37
|
+
setValues: vi.fn(),
|
|
38
|
+
validateField: vi.fn(),
|
|
39
|
+
validateForm: vi.fn(),
|
|
40
|
+
submitForm: vi.fn(),
|
|
41
|
+
resetForm: vi.fn(),
|
|
42
|
+
getFieldProps: vi.fn(),
|
|
43
|
+
getSelectFieldProps: vi.fn(),
|
|
44
|
+
getArrayHelpers: vi.fn(),
|
|
45
|
+
on: vi.fn(),
|
|
46
|
+
...overrides,
|
|
47
|
+
} as unknown as UseFormaReturn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderWithContext(
|
|
51
|
+
ui: React.ReactElement,
|
|
52
|
+
contextOverrides: Partial<UseFormaReturn> = {},
|
|
53
|
+
) {
|
|
54
|
+
const ctx = mockFormaContext(contextOverrides);
|
|
55
|
+
return render(
|
|
56
|
+
<FormaContext.Provider value={ctx}>{ui}</FormaContext.Provider>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// FieldWrapper
|
|
62
|
+
// ============================================================================
|
|
63
|
+
describe("FieldWrapper", () => {
|
|
64
|
+
function makeProps(
|
|
65
|
+
overrides: Partial<FieldWrapperProps> = {},
|
|
66
|
+
): FieldWrapperProps {
|
|
67
|
+
return {
|
|
68
|
+
fieldPath: "testField",
|
|
69
|
+
field: {
|
|
70
|
+
type: "text",
|
|
71
|
+
label: "Test Field",
|
|
72
|
+
} as FieldWrapperProps["field"],
|
|
73
|
+
children: <input type="text" />,
|
|
74
|
+
errors: [],
|
|
75
|
+
touched: false,
|
|
76
|
+
required: false,
|
|
77
|
+
showRequiredIndicator: false,
|
|
78
|
+
visible: true,
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it("renders label with field name", () => {
|
|
84
|
+
renderWithContext(<FieldWrapper {...makeProps()} />);
|
|
85
|
+
expect(screen.getByText("Test Field")).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows required indicator when showRequiredIndicator is true", () => {
|
|
89
|
+
renderWithContext(
|
|
90
|
+
<FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByText("*")).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("hides required indicator when showRequiredIndicator is false", () => {
|
|
96
|
+
renderWithContext(
|
|
97
|
+
<FieldWrapper {...makeProps({ showRequiredIndicator: false })} />,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders description when provided", () => {
|
|
103
|
+
renderWithContext(
|
|
104
|
+
<FieldWrapper
|
|
105
|
+
{...makeProps({
|
|
106
|
+
field: {
|
|
107
|
+
type: "text",
|
|
108
|
+
label: "Test Field",
|
|
109
|
+
description: "Help text here",
|
|
110
|
+
} as FieldWrapperProps["field"],
|
|
111
|
+
})}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
expect(screen.getByText("Help text here")).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders children", () => {
|
|
118
|
+
renderWithContext(
|
|
119
|
+
<FieldWrapper
|
|
120
|
+
{...makeProps({ children: <span>Child content</span> })}
|
|
121
|
+
/>,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByText("Child content")).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does NOT render errors when touched is false and not submitted", () => {
|
|
127
|
+
const errors: FieldError[] = [
|
|
128
|
+
{ field: "testField", message: "Required", severity: "error" },
|
|
129
|
+
];
|
|
130
|
+
renderWithContext(
|
|
131
|
+
<FieldWrapper {...makeProps({ errors, touched: false })} />,
|
|
132
|
+
);
|
|
133
|
+
expect(screen.queryByText("Required")).not.toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("renders errors when touched is true and errors exist", () => {
|
|
137
|
+
const errors: FieldError[] = [
|
|
138
|
+
{ field: "testField", message: "Required", severity: "error" },
|
|
139
|
+
];
|
|
140
|
+
renderWithContext(
|
|
141
|
+
<FieldWrapper {...makeProps({ errors, touched: true })} />,
|
|
142
|
+
);
|
|
143
|
+
expect(screen.getByText("Required")).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("renders errors when isSubmitted is true even if not touched", () => {
|
|
147
|
+
const errors: FieldError[] = [
|
|
148
|
+
{ field: "testField", message: "Required", severity: "error" },
|
|
149
|
+
];
|
|
150
|
+
renderWithContext(
|
|
151
|
+
<FieldWrapper {...makeProps({ errors, touched: false })} />,
|
|
152
|
+
{ isSubmitted: true },
|
|
153
|
+
);
|
|
154
|
+
expect(screen.getByText("Required")).toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("error container has role=alert", () => {
|
|
158
|
+
const errors: FieldError[] = [
|
|
159
|
+
{ field: "testField", message: "Required", severity: "error" },
|
|
160
|
+
];
|
|
161
|
+
renderWithContext(
|
|
162
|
+
<FieldWrapper {...makeProps({ errors, touched: true })} />,
|
|
163
|
+
);
|
|
164
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns null when visible is false", () => {
|
|
168
|
+
const { container } = renderWithContext(
|
|
169
|
+
<FieldWrapper {...makeProps({ visible: false })} />,
|
|
170
|
+
);
|
|
171
|
+
expect(container.firstChild).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("applies forma-field--error class when has visible errors", () => {
|
|
175
|
+
const errors: FieldError[] = [
|
|
176
|
+
{ field: "testField", message: "Required", severity: "error" },
|
|
177
|
+
];
|
|
178
|
+
const { container } = renderWithContext(
|
|
179
|
+
<FieldWrapper {...makeProps({ errors, touched: true })} />,
|
|
180
|
+
);
|
|
181
|
+
expect(container.firstChild).toHaveClass("forma-field--error");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("applies forma-field--required class when showRequiredIndicator", () => {
|
|
185
|
+
const { container } = renderWithContext(
|
|
186
|
+
<FieldWrapper {...makeProps({ showRequiredIndicator: true })} />,
|
|
187
|
+
);
|
|
188
|
+
expect(container.firstChild).toHaveClass("forma-field--required");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ============================================================================
|
|
193
|
+
// FormLayout
|
|
194
|
+
// ============================================================================
|
|
195
|
+
describe("FormLayout", () => {
|
|
196
|
+
it("renders as form element", () => {
|
|
197
|
+
const { container } = render(
|
|
198
|
+
<FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
|
|
199
|
+
<div>Content</div>
|
|
200
|
+
</FormLayout>,
|
|
201
|
+
);
|
|
202
|
+
expect(container.querySelector("form")).toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("calls onSubmit on form submit", () => {
|
|
206
|
+
const onSubmit = vi.fn();
|
|
207
|
+
const { container } = render(
|
|
208
|
+
<FormLayout onSubmit={onSubmit} isSubmitting={false} isValid={true}>
|
|
209
|
+
<div>Content</div>
|
|
210
|
+
</FormLayout>,
|
|
211
|
+
);
|
|
212
|
+
fireEvent.submit(container.querySelector("form")!);
|
|
213
|
+
expect(onSubmit).toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("renders submit button with Submit text", () => {
|
|
217
|
+
render(
|
|
218
|
+
<FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
|
|
219
|
+
<div>Content</div>
|
|
220
|
+
</FormLayout>,
|
|
221
|
+
);
|
|
222
|
+
expect(screen.getByRole("button")).toHaveTextContent("Submit");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("shows Submitting... and disables button when isSubmitting", () => {
|
|
226
|
+
render(
|
|
227
|
+
<FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
|
|
228
|
+
<div>Content</div>
|
|
229
|
+
</FormLayout>,
|
|
230
|
+
);
|
|
231
|
+
const button = screen.getByRole("button");
|
|
232
|
+
expect(button).toHaveTextContent("Submitting...");
|
|
233
|
+
expect(button).toBeDisabled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("sets aria-busy on button when submitting", () => {
|
|
237
|
+
render(
|
|
238
|
+
<FormLayout onSubmit={vi.fn()} isSubmitting={true} isValid={true}>
|
|
239
|
+
<div>Content</div>
|
|
240
|
+
</FormLayout>,
|
|
241
|
+
);
|
|
242
|
+
expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("renders children inside form", () => {
|
|
246
|
+
render(
|
|
247
|
+
<FormLayout onSubmit={vi.fn()} isSubmitting={false} isValid={true}>
|
|
248
|
+
<div>Form content</div>
|
|
249
|
+
</FormLayout>,
|
|
250
|
+
);
|
|
251
|
+
expect(screen.getByText("Form content")).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// PageWrapper
|
|
257
|
+
// ============================================================================
|
|
258
|
+
describe("PageWrapper", () => {
|
|
259
|
+
function makePageProps(
|
|
260
|
+
overrides: Partial<PageWrapperProps> = {},
|
|
261
|
+
): PageWrapperProps {
|
|
262
|
+
return {
|
|
263
|
+
title: "Page Title",
|
|
264
|
+
children: <div>Page content</div>,
|
|
265
|
+
pageIndex: 0,
|
|
266
|
+
totalPages: 3,
|
|
267
|
+
...overrides,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it("renders title as h2", () => {
|
|
272
|
+
render(<PageWrapper {...makePageProps()} />);
|
|
273
|
+
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
|
|
274
|
+
"Page Title",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("renders description when provided", () => {
|
|
279
|
+
render(
|
|
280
|
+
<PageWrapper
|
|
281
|
+
{...makePageProps({ description: "Some description" })}
|
|
282
|
+
/>,
|
|
283
|
+
);
|
|
284
|
+
expect(screen.getByText("Some description")).toBeInTheDocument();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("omits description element when not provided", () => {
|
|
288
|
+
const { container } = render(<PageWrapper {...makePageProps()} />);
|
|
289
|
+
expect(
|
|
290
|
+
container.querySelector(".forma-page__description"),
|
|
291
|
+
).not.toBeInTheDocument();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("renders children", () => {
|
|
295
|
+
render(<PageWrapper {...makePageProps()} />);
|
|
296
|
+
expect(screen.getByText("Page content")).toBeInTheDocument();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import { FormRenderer } from "../FormRenderer.js";
|
|
3
|
+
import type { FormRendererProps, FormRendererHandle } from "../FormRenderer.js";
|
|
4
|
+
import {
|
|
5
|
+
defaultComponentMap,
|
|
6
|
+
defaultFieldWrapper,
|
|
7
|
+
defaultLayout,
|
|
8
|
+
defaultWizardLayout,
|
|
9
|
+
defaultPageWrapper,
|
|
10
|
+
} from "./componentMap.js";
|
|
11
|
+
|
|
12
|
+
export interface DefaultFormRendererProps
|
|
13
|
+
extends Omit<FormRendererProps, "components"> {
|
|
14
|
+
/** Component map (defaults to defaultComponentMap if not provided) */
|
|
15
|
+
components?: FormRendererProps["components"];
|
|
16
|
+
/** Use wizard layout for multi-page forms */
|
|
17
|
+
wizardLayout?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DefaultFormRenderer = forwardRef<
|
|
21
|
+
FormRendererHandle,
|
|
22
|
+
DefaultFormRendererProps
|
|
23
|
+
>(function DefaultFormRenderer(props, ref) {
|
|
24
|
+
const {
|
|
25
|
+
components,
|
|
26
|
+
wizardLayout,
|
|
27
|
+
layout,
|
|
28
|
+
fieldWrapper,
|
|
29
|
+
pageWrapper,
|
|
30
|
+
...rest
|
|
31
|
+
} = props;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<FormRenderer
|
|
35
|
+
ref={ref}
|
|
36
|
+
components={components ?? defaultComponentMap}
|
|
37
|
+
fieldWrapper={fieldWrapper ?? defaultFieldWrapper}
|
|
38
|
+
layout={layout ?? (wizardLayout ? defaultWizardLayout : defaultLayout)}
|
|
39
|
+
pageWrapper={pageWrapper ?? defaultPageWrapper}
|
|
40
|
+
{...rest}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentMap } from "../types.js";
|
|
2
|
+
import { TextInput } from "./components/TextInput.js";
|
|
3
|
+
import { TextareaInput } from "./components/TextareaInput.js";
|
|
4
|
+
import { NumberInput, IntegerInput } from "./components/NumberInput.js";
|
|
5
|
+
import { BooleanInput } from "./components/BooleanInput.js";
|
|
6
|
+
import { DateInput, DateTimeInput } from "./components/DateInput.js";
|
|
7
|
+
import { SelectInput } from "./components/SelectInput.js";
|
|
8
|
+
import { MultiSelectInput } from "./components/MultiSelectInput.js";
|
|
9
|
+
import { ArrayField } from "./components/ArrayField.js";
|
|
10
|
+
import { ObjectField } from "./components/ObjectField.js";
|
|
11
|
+
import { ComputedDisplay } from "./components/ComputedDisplay.js";
|
|
12
|
+
import { DisplayField } from "./components/DisplayField.js";
|
|
13
|
+
import { MatrixField } from "./components/MatrixField.js";
|
|
14
|
+
import { FallbackField } from "./components/FallbackField.js";
|
|
15
|
+
import { FieldWrapper } from "./layout/FieldWrapper.js";
|
|
16
|
+
import { FormLayout } from "./layout/FormLayout.js";
|
|
17
|
+
import { WizardLayout } from "./layout/WizardLayout.js";
|
|
18
|
+
import { PageWrapper } from "./layout/PageWrapper.js";
|
|
19
|
+
|
|
20
|
+
export const defaultComponentMap: ComponentMap = {
|
|
21
|
+
text: TextInput,
|
|
22
|
+
email: TextInput,
|
|
23
|
+
phone: TextInput,
|
|
24
|
+
url: TextInput,
|
|
25
|
+
password: TextInput,
|
|
26
|
+
textarea: TextareaInput,
|
|
27
|
+
number: NumberInput,
|
|
28
|
+
integer: IntegerInput,
|
|
29
|
+
boolean: BooleanInput,
|
|
30
|
+
date: DateInput,
|
|
31
|
+
datetime: DateTimeInput,
|
|
32
|
+
select: SelectInput,
|
|
33
|
+
multiselect: MultiSelectInput,
|
|
34
|
+
array: ArrayField,
|
|
35
|
+
object: ObjectField,
|
|
36
|
+
computed: ComputedDisplay,
|
|
37
|
+
display: DisplayField,
|
|
38
|
+
matrix: MatrixField,
|
|
39
|
+
fallback: FallbackField,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const defaultFieldWrapper = FieldWrapper;
|
|
43
|
+
export const defaultLayout = FormLayout;
|
|
44
|
+
export const defaultWizardLayout = WizardLayout;
|
|
45
|
+
export const defaultPageWrapper = PageWrapper;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import type { ArrayComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function ArrayField({ field }: ArrayComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
|
|
7
|
+
// Stable keys via ref-based sequential counter
|
|
8
|
+
const itemKeysRef = useRef<string[]>([]);
|
|
9
|
+
const nextKeyRef = useRef(0);
|
|
10
|
+
|
|
11
|
+
const currentLength = field.helpers.items.length;
|
|
12
|
+
const keysLength = itemKeysRef.current.length;
|
|
13
|
+
|
|
14
|
+
if (currentLength > keysLength) {
|
|
15
|
+
for (let i = keysLength; i < currentLength; i++) {
|
|
16
|
+
itemKeysRef.current.push(`item-${nextKeyRef.current++}`);
|
|
17
|
+
}
|
|
18
|
+
} else if (currentLength < keysLength) {
|
|
19
|
+
itemKeysRef.current.length = currentLength;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fieldOrder =
|
|
23
|
+
field.itemFieldOrder ?? Object.keys(field.itemFields);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="forma-array"
|
|
28
|
+
aria-invalid={hasErrors || undefined}
|
|
29
|
+
>
|
|
30
|
+
{field.helpers.items.length === 0 && (
|
|
31
|
+
<p className="forma-array__empty">No items</p>
|
|
32
|
+
)}
|
|
33
|
+
{field.helpers.items.map((_, index) => (
|
|
34
|
+
<div
|
|
35
|
+
key={itemKeysRef.current[index]}
|
|
36
|
+
className="forma-array__item"
|
|
37
|
+
>
|
|
38
|
+
<div className="forma-array__item-fields">
|
|
39
|
+
{fieldOrder.map((fieldName) => {
|
|
40
|
+
const itemProps = field.helpers.getItemFieldProps(
|
|
41
|
+
index,
|
|
42
|
+
fieldName,
|
|
43
|
+
);
|
|
44
|
+
return (
|
|
45
|
+
<div key={fieldName} className="forma-field">
|
|
46
|
+
<label
|
|
47
|
+
htmlFor={itemProps.name}
|
|
48
|
+
className="forma-label"
|
|
49
|
+
>
|
|
50
|
+
{itemProps.label}
|
|
51
|
+
</label>
|
|
52
|
+
{renderItemField(itemProps)}
|
|
53
|
+
{itemProps.errors.length > 0 && itemProps.touched && (
|
|
54
|
+
<div className="forma-field__errors" role="alert">
|
|
55
|
+
{itemProps.errors.map((err, i) => (
|
|
56
|
+
<span key={i} className="forma-field__error">
|
|
57
|
+
{err.message}
|
|
58
|
+
</span>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="forma-button forma-button--danger forma-array__remove"
|
|
69
|
+
onClick={() => field.helpers.remove(index)}
|
|
70
|
+
disabled={!field.helpers.canRemove || field.disabled}
|
|
71
|
+
>
|
|
72
|
+
Remove
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
))}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className="forma-button forma-button--secondary forma-array__add"
|
|
79
|
+
onClick={() => field.helpers.push()}
|
|
80
|
+
disabled={!field.helpers.canAdd || field.disabled}
|
|
81
|
+
>
|
|
82
|
+
+ Add Item
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderItemField(
|
|
89
|
+
itemProps: ReturnType<
|
|
90
|
+
ArrayComponentProps["field"]["helpers"]["getItemFieldProps"]
|
|
91
|
+
>,
|
|
92
|
+
) {
|
|
93
|
+
const type = itemProps.type;
|
|
94
|
+
|
|
95
|
+
if (type === "select" && itemProps.options) {
|
|
96
|
+
return (
|
|
97
|
+
<select
|
|
98
|
+
id={itemProps.name}
|
|
99
|
+
className="forma-select"
|
|
100
|
+
value={String(itemProps.value ?? "")}
|
|
101
|
+
onChange={(e) => {
|
|
102
|
+
const value = e.target.value;
|
|
103
|
+
if (!value) {
|
|
104
|
+
itemProps.onChange(null);
|
|
105
|
+
} else {
|
|
106
|
+
const option = itemProps.options?.find(
|
|
107
|
+
(opt) => String(opt.value) === value,
|
|
108
|
+
);
|
|
109
|
+
itemProps.onChange(option ? option.value : value);
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
onBlur={itemProps.onBlur}
|
|
113
|
+
disabled={!itemProps.enabled}
|
|
114
|
+
>
|
|
115
|
+
<option value="">Select...</option>
|
|
116
|
+
{itemProps.options.map((opt) => (
|
|
117
|
+
<option key={String(opt.value)} value={String(opt.value)}>
|
|
118
|
+
{opt.label}
|
|
119
|
+
</option>
|
|
120
|
+
))}
|
|
121
|
+
</select>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (type === "number" || type === "integer") {
|
|
126
|
+
return (
|
|
127
|
+
<input
|
|
128
|
+
id={itemProps.name}
|
|
129
|
+
type="number"
|
|
130
|
+
className="forma-input"
|
|
131
|
+
value={itemProps.value != null ? String(itemProps.value) : ""}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
const val = e.target.value;
|
|
134
|
+
if (val === "") {
|
|
135
|
+
itemProps.onChange(null);
|
|
136
|
+
} else {
|
|
137
|
+
const num =
|
|
138
|
+
type === "integer"
|
|
139
|
+
? parseInt(val, 10)
|
|
140
|
+
: parseFloat(val);
|
|
141
|
+
itemProps.onChange(isNaN(num) ? null : num);
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
onBlur={itemProps.onBlur}
|
|
145
|
+
disabled={!itemProps.enabled}
|
|
146
|
+
step={type === "integer" ? 1 : "any"}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (type === "boolean") {
|
|
152
|
+
return (
|
|
153
|
+
<div className="forma-checkbox">
|
|
154
|
+
<input
|
|
155
|
+
id={itemProps.name}
|
|
156
|
+
type="checkbox"
|
|
157
|
+
className="forma-checkbox__input"
|
|
158
|
+
checked={Boolean(itemProps.value)}
|
|
159
|
+
onChange={(e) => itemProps.onChange(e.target.checked)}
|
|
160
|
+
onBlur={itemProps.onBlur}
|
|
161
|
+
disabled={!itemProps.enabled}
|
|
162
|
+
/>
|
|
163
|
+
<label htmlFor={itemProps.name} className="forma-checkbox__label">
|
|
164
|
+
{itemProps.label}
|
|
165
|
+
</label>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Default: text input
|
|
171
|
+
return (
|
|
172
|
+
<input
|
|
173
|
+
id={itemProps.name}
|
|
174
|
+
type="text"
|
|
175
|
+
className="forma-input"
|
|
176
|
+
value={String(itemProps.value ?? "")}
|
|
177
|
+
onChange={(e) => itemProps.onChange(e.target.value)}
|
|
178
|
+
onBlur={itemProps.onBlur}
|
|
179
|
+
disabled={!itemProps.enabled}
|
|
180
|
+
placeholder={itemProps.placeholder}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { BooleanComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function BooleanInput({ field }: BooleanComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="forma-checkbox">
|
|
15
|
+
<input
|
|
16
|
+
id={field.name}
|
|
17
|
+
name={field.name}
|
|
18
|
+
type="checkbox"
|
|
19
|
+
className="forma-checkbox__input"
|
|
20
|
+
checked={field.value ?? false}
|
|
21
|
+
onChange={(e) => field.onChange(e.target.checked)}
|
|
22
|
+
onBlur={field.onBlur}
|
|
23
|
+
disabled={field.disabled}
|
|
24
|
+
aria-invalid={hasErrors || undefined}
|
|
25
|
+
aria-describedby={describedBy || undefined}
|
|
26
|
+
/>
|
|
27
|
+
<label htmlFor={field.name} className="forma-checkbox__label">
|
|
28
|
+
{field.label}
|
|
29
|
+
</label>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ComputedComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function ComputedDisplay({ field }: ComputedComponentProps) {
|
|
5
|
+
let displayValue: string;
|
|
6
|
+
if (field.value === null || field.value === undefined) {
|
|
7
|
+
displayValue = "\u2014";
|
|
8
|
+
} else if (typeof field.value === "object") {
|
|
9
|
+
try {
|
|
10
|
+
displayValue = JSON.stringify(field.value);
|
|
11
|
+
} catch {
|
|
12
|
+
displayValue = String(field.value);
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
displayValue = String(field.value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<output
|
|
20
|
+
id={field.name}
|
|
21
|
+
className="forma-computed"
|
|
22
|
+
>
|
|
23
|
+
{displayValue}
|
|
24
|
+
</output>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { DateComponentProps, DateTimeComponentProps } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
export function DateInput({ field }: DateComponentProps) {
|
|
5
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
6
|
+
const describedBy = [
|
|
7
|
+
field.description ? `${field.name}-description` : null,
|
|
8
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<input
|
|
15
|
+
id={field.name}
|
|
16
|
+
name={field.name}
|
|
17
|
+
type="date"
|
|
18
|
+
className="forma-input forma-input--date"
|
|
19
|
+
value={field.value ?? ""}
|
|
20
|
+
onChange={(e) => field.onChange(e.target.value || null)}
|
|
21
|
+
onBlur={field.onBlur}
|
|
22
|
+
disabled={field.disabled}
|
|
23
|
+
readOnly={field.readonly}
|
|
24
|
+
aria-invalid={hasErrors || undefined}
|
|
25
|
+
aria-required={field.required || undefined}
|
|
26
|
+
aria-describedby={describedBy || undefined}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DateTimeInput({ field }: DateTimeComponentProps) {
|
|
32
|
+
const hasErrors = field.visibleErrors.length > 0;
|
|
33
|
+
const describedBy = [
|
|
34
|
+
field.description ? `${field.name}-description` : null,
|
|
35
|
+
hasErrors ? `${field.name}-errors` : null,
|
|
36
|
+
]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join(" ");
|
|
39
|
+
|
|
40
|
+
// datetime-local expects "YYYY-MM-DDTHH:mm" format
|
|
41
|
+
const inputValue = field.value ?? "";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<input
|
|
45
|
+
id={field.name}
|
|
46
|
+
name={field.name}
|
|
47
|
+
type="datetime-local"
|
|
48
|
+
className="forma-input forma-input--datetime"
|
|
49
|
+
value={inputValue}
|
|
50
|
+
onChange={(e) => field.onChange(e.target.value || null)}
|
|
51
|
+
onBlur={field.onBlur}
|
|
52
|
+
disabled={field.disabled}
|
|
53
|
+
readOnly={field.readonly}
|
|
54
|
+
aria-invalid={hasErrors || undefined}
|
|
55
|
+
aria-required={field.required || undefined}
|
|
56
|
+
aria-describedby={describedBy || undefined}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|