@sanity/form-toolkit 1.1.0 → 1.2.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 CHANGED
@@ -158,6 +158,85 @@ export default defineType({
158
158
  })
159
159
  ```
160
160
 
161
+ ## formSchema and FormRenderer
162
+
163
+ The `formSchema` plugin and `FormRenderer` React component are designed to be used together to build and render forms in Sanity. `formSchema` provides a Sanity schema for creating `form` documents made up of various `formFields`, then `FormRenderer` takes those `form` documents and renders them as React components. The `formSchema` plugin can be used by itself with your own logic for rendering the form. The `/examples` directory of this repository shows `FormRenderer` being used with popular form libraries.
164
+
165
+ First add `formSchema` it as a plugin in `sanity.config.ts` (or .js):
166
+
167
+ ```ts
168
+ import {defineConfig} from 'sanity'
169
+ import {formSchema} from '@sanity/form-toolkit'
170
+
171
+ export default defineConfig({
172
+ //...
173
+ plugins: [formSchema()],
174
+ })
175
+ ```
176
+
177
+ Then pass a `form` document to the `FormRenderer` component
178
+
179
+ ```tsx
180
+ import React, {type FC} from 'react'
181
+ import {FormRenderer, type FormDataProps} from '@sanity/form-toolkit'
182
+
183
+ interface NativeFormExampleProps {
184
+ formData: FormDataProps
185
+ action?: string
186
+ method?: 'get' | 'post'
187
+ }
188
+ /**
189
+ * Example of using the `FormRenderer` as a native HTML form element.
190
+ */
191
+ export const NativeFormExample: FC<NativeFormExampleProps> = ({
192
+ formData, // form document from Sanity
193
+ action = '/api/submit',
194
+ method = 'post',
195
+ }) => {
196
+ return (
197
+ <FormRenderer
198
+ formData={formData}
199
+ action={action}
200
+ method={method}
201
+ encType="multipart/form-data"
202
+ />
203
+ )
204
+ }
205
+ ```
206
+
207
+ ### FormRenderer
208
+
209
+ The `FormRenderer` component takes documents created with the `formSchema` plugin and renders a form for your front-end. The `formSchema` plugin can be used by itself with your own logic for rendering the form. The `/examples` directory of this repository shows `FormRenderer` being used with popular form libraries. `FormRenderer` takes the following props
210
+
211
+ #### All props for native `form` element
212
+
213
+ `FormRenderer` can take all the typical props passed to the `form` element in React like `action`, `onSubmit`, `className`, etc.
214
+
215
+ #### formData
216
+
217
+ A `form` document created with the `formSchema` plugin.
218
+
219
+ #### fieldComponents
220
+
221
+ An object where the keys are possible input field type names and the values are components for that field's input.
222
+
223
+ ```tsx
224
+ const fieldComponents = {
225
+ select: MyCustomSelectComponent
226
+ }
227
+ <FormRenderer
228
+ fieldComponents={fieldComponents}
229
+ />
230
+ ```
231
+
232
+ #### getFieldState
233
+
234
+ Function for managing each field as a piece of state (see `react-hook-form.tsx` and `tanstack-form.tsx` in the `/examples` directory)
235
+
236
+ #### getFieldError
237
+
238
+ Similar to `getFieldState`, a function for managing each field's errors as a piece of state (see `react-hook-form.tsx` and `tanstack-form.tsx` in the `/examples` directory)
239
+
161
240
  ## License
162
241
 
163
242
  [MIT](LICENSE) © Chris LaRocque
@@ -170,7 +249,6 @@ with default configuration for build & watch scripts.
170
249
  See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
171
250
  on how to run this plugin with hotreload in the studio.
172
251
 
173
-
174
252
  ### Release new version
175
253
 
176
254
  Run ["CI & Release" workflow](https://github.com/sanity-io/form-toolkit/actions/workflows/main.yml).
package/dist/index.d.mts CHANGED
@@ -1,5 +1,8 @@
1
+ import type {ComponentType} from 'react'
1
2
  import {EventHandler} from 'h3'
2
3
  import {EventHandlerRequest} from 'h3'
4
+ import type {FC} from 'react'
5
+ import type {HTMLProps} from 'react'
3
6
  import {IncomingMessage} from 'http'
4
7
  import {Plugin as Plugin_2} from 'sanity'
5
8
  import {ServerResponse} from 'http'
@@ -14,6 +17,63 @@ export declare function fetchMailchimpData({
14
17
  server: string
15
18
  }): Promise<unknown>
16
19
 
20
+ declare type FieldChoice = {
21
+ label: string
22
+ value: string
23
+ }
24
+
25
+ declare interface FieldComponentProps {
26
+ field: FormField
27
+ fieldState: FieldState
28
+ error?: string
29
+ }
30
+
31
+ declare type FieldOptions = {
32
+ placeholder?: string
33
+ defaultValue?: string
34
+ }
35
+
36
+ declare interface FieldState {
37
+ value?: string | number | readonly string[]
38
+ onChange: (value: unknown) => void
39
+ onBlur?: () => void
40
+ ref?: unknown
41
+ }
42
+
43
+ export declare type FormDataProps = {
44
+ title: string
45
+ id: {
46
+ current: string
47
+ }
48
+ fields?: FormField[]
49
+ submitButton?: {
50
+ text: string
51
+ position: 'left' | 'center' | 'right'
52
+ }
53
+ }
54
+
55
+ declare type FormField = {
56
+ type: string
57
+ label?: string
58
+ name: string
59
+ required: boolean
60
+ validation?: ValidationRule[]
61
+ options?: FieldOptions
62
+ choices?: FieldChoice[]
63
+ _key: string
64
+ }
65
+
66
+ export declare const FormRenderer: FC<FormRendererProps>
67
+
68
+ declare interface FormRendererProps extends HTMLProps<HTMLFormElement> {
69
+ formData: FormDataProps
70
+ getFieldState?: (fieldName: string) => FieldState
71
+ getFieldError?: (fieldName: string) => string | undefined
72
+ fieldComponents?: Record<string, ComponentType<FieldComponentProps>>
73
+ }
74
+
75
+ export declare const formSchema: Plugin_2<void>
76
+
17
77
  declare type HubSpotForm = {
18
78
  id: string
19
79
  name: string
@@ -97,4 +157,10 @@ declare type MappedResult = HubSpotForm & {
97
157
  value: string
98
158
  }
99
159
 
160
+ declare type ValidationRule = {
161
+ type: string
162
+ value: string
163
+ message: string
164
+ }
165
+
100
166
  export {}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import type {ComponentType} from 'react'
1
2
  import {EventHandler} from 'h3'
2
3
  import {EventHandlerRequest} from 'h3'
4
+ import type {FC} from 'react'
5
+ import type {HTMLProps} from 'react'
3
6
  import {IncomingMessage} from 'http'
4
7
  import {Plugin as Plugin_2} from 'sanity'
5
8
  import {ServerResponse} from 'http'
@@ -14,6 +17,63 @@ export declare function fetchMailchimpData({
14
17
  server: string
15
18
  }): Promise<unknown>
16
19
 
20
+ declare type FieldChoice = {
21
+ label: string
22
+ value: string
23
+ }
24
+
25
+ declare interface FieldComponentProps {
26
+ field: FormField
27
+ fieldState: FieldState
28
+ error?: string
29
+ }
30
+
31
+ declare type FieldOptions = {
32
+ placeholder?: string
33
+ defaultValue?: string
34
+ }
35
+
36
+ declare interface FieldState {
37
+ value?: string | number | readonly string[]
38
+ onChange: (value: unknown) => void
39
+ onBlur?: () => void
40
+ ref?: unknown
41
+ }
42
+
43
+ export declare type FormDataProps = {
44
+ title: string
45
+ id: {
46
+ current: string
47
+ }
48
+ fields?: FormField[]
49
+ submitButton?: {
50
+ text: string
51
+ position: 'left' | 'center' | 'right'
52
+ }
53
+ }
54
+
55
+ declare type FormField = {
56
+ type: string
57
+ label?: string
58
+ name: string
59
+ required: boolean
60
+ validation?: ValidationRule[]
61
+ options?: FieldOptions
62
+ choices?: FieldChoice[]
63
+ _key: string
64
+ }
65
+
66
+ export declare const FormRenderer: FC<FormRendererProps>
67
+
68
+ declare interface FormRendererProps extends HTMLProps<HTMLFormElement> {
69
+ formData: FormDataProps
70
+ getFieldState?: (fieldName: string) => FieldState
71
+ getFieldError?: (fieldName: string) => string | undefined
72
+ fieldComponents?: Record<string, ComponentType<FieldComponentProps>>
73
+ }
74
+
75
+ export declare const formSchema: Plugin_2<void>
76
+
17
77
  declare type HubSpotForm = {
18
78
  id: string
19
79
  name: string
@@ -97,4 +157,10 @@ declare type MappedResult = HubSpotForm & {
97
157
  value: string
98
158
  }
99
159
 
160
+ declare type ValidationRule = {
161
+ type: string
162
+ value: string
163
+ message: string
164
+ }
165
+
100
166
  export {}
package/dist/index.js CHANGED
@@ -1,11 +1,339 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: !0 });
3
- var sanityPluginAsyncList = require("@sanity/sanity-plugin-async-list"), sanity = require("sanity"), jsxRuntime = require("react/jsx-runtime"), ui = require("@sanity/ui"), h3 = require("h3"), mailchimp = require("@mailchimp/mailchimp_marketing");
3
+ var sanity = require("sanity"), fa = require("react-icons/fa"), lu = require("react-icons/lu"), sanityPluginAsyncList = require("@sanity/sanity-plugin-async-list"), jsxRuntime = require("react/jsx-runtime"), ui = require("@sanity/ui"), h3 = require("h3"), mailchimp = require("@mailchimp/mailchimp_marketing");
4
4
  function _interopDefaultCompat(e) {
5
5
  return e && typeof e == "object" && "default" in e ? e : { default: e };
6
6
  }
7
7
  var mailchimp__default = /* @__PURE__ */ _interopDefaultCompat(mailchimp);
8
- const Option$1 = (option) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Card, { "data-as": "button", padding: 3, radius: 2, tone: "inherit", children: [
8
+ const DefaultField = ({ field, fieldState, error }) => {
9
+ const { type, label, name, options = {}, choices = [] } = field;
10
+ if (!type || !name) return null;
11
+ const { value, onChange, onBlur, ref } = fieldState, handleChange = (e) => {
12
+ onChange(e.target.value);
13
+ }, handleCheckboxChange = (e, choiceValue) => {
14
+ if (Array.isArray(value)) {
15
+ const newValue = e.target.checked ? [...value, choiceValue] : value.filter((v) => v !== choiceValue);
16
+ onChange(newValue);
17
+ } else
18
+ onChange(e.target.checked ? choiceValue : "");
19
+ }, renderInput = () => {
20
+ switch (type) {
21
+ case "textarea":
22
+ return /* @__PURE__ */ jsxRuntime.jsx(
23
+ "textarea",
24
+ {
25
+ ref,
26
+ name,
27
+ value: value ?? "",
28
+ onChange: handleChange,
29
+ onBlur,
30
+ placeholder: options.placeholder
31
+ }
32
+ );
33
+ case "select":
34
+ return /* @__PURE__ */ jsxRuntime.jsx(
35
+ "select",
36
+ {
37
+ ref,
38
+ name,
39
+ value: value ?? "",
40
+ onChange: handleChange,
41
+ onBlur,
42
+ children: choices?.map((choice, i) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: choice.value, children: choice.label }, i))
43
+ }
44
+ );
45
+ case "radio":
46
+ return choices?.map((choice, i) => /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
47
+ /* @__PURE__ */ jsxRuntime.jsx(
48
+ "input",
49
+ {
50
+ type: "radio",
51
+ name,
52
+ ref,
53
+ value: choice.value,
54
+ checked: value === choice.value,
55
+ onChange: handleChange,
56
+ onBlur
57
+ }
58
+ ),
59
+ choice.label
60
+ ] }, i));
61
+ case "checkbox":
62
+ return choices?.map((choice, i) => /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
63
+ /* @__PURE__ */ jsxRuntime.jsx(
64
+ "input",
65
+ {
66
+ type: "checkbox",
67
+ name,
68
+ ref,
69
+ value: choice.value,
70
+ checked: Array.isArray(value) ? value.includes(choice.value) : value === choice.value,
71
+ onChange: (e) => handleCheckboxChange(e, choice.value),
72
+ onBlur
73
+ }
74
+ ),
75
+ choice.label
76
+ ] }, i));
77
+ default:
78
+ return /* @__PURE__ */ jsxRuntime.jsx(
79
+ "input",
80
+ {
81
+ type,
82
+ ref,
83
+ name,
84
+ value: value ?? options.defaultValue ?? "",
85
+ onChange: handleChange,
86
+ onBlur,
87
+ placeholder: options.placeholder
88
+ }
89
+ );
90
+ }
91
+ };
92
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
93
+ label && type != "hidden" && /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: name, children: label }),
94
+ renderInput(),
95
+ error && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "error", children: error })
96
+ ] });
97
+ }, FormRenderer = (props) => {
98
+ const {
99
+ formData,
100
+ getFieldState = (name) => ({
101
+ value: void 0,
102
+ onChange: () => {
103
+ },
104
+ name
105
+ // Pass name to field for native form handling
106
+ }),
107
+ getFieldError,
108
+ fieldComponents = {},
109
+ children
110
+ } = props, renderField = (field) => {
111
+ const CustomComponent = fieldComponents[field.type], fieldState = getFieldState(field.name), error = getFieldError?.(field.name);
112
+ return CustomComponent ? /* @__PURE__ */ jsxRuntime.jsx(CustomComponent, { field, fieldState, error }) : /* @__PURE__ */ jsxRuntime.jsx(DefaultField, { field, fieldState, error });
113
+ };
114
+ return /* @__PURE__ */ jsxRuntime.jsxs("form", { ...props, id: props.id ?? formData?.id?.current, children: [
115
+ formData.fields?.map((field) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "form-field", children: renderField(field) }, field._key)),
116
+ children,
117
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "submit", children: formData.submitButton?.text || "Submit" })
118
+ ] });
119
+ }, formType = sanity.defineType({
120
+ name: "form",
121
+ title: "Form",
122
+ type: "document",
123
+ icon: fa.FaWpforms,
124
+ fields: [
125
+ sanity.defineField({
126
+ name: "title",
127
+ title: "Form Title",
128
+ type: "string",
129
+ description: "Internal title for the form",
130
+ validation: (Rule) => Rule.required()
131
+ }),
132
+ sanity.defineField({
133
+ name: "id",
134
+ title: "Form ID",
135
+ type: "slug",
136
+ options: {
137
+ source: "title"
138
+ }
139
+ // validation: (Rule) => Rule.required(),
140
+ }),
141
+ sanity.defineField({
142
+ name: "fields",
143
+ title: "Form Fields",
144
+ type: "array",
145
+ of: [{ type: "formField" }]
146
+ }),
147
+ sanity.defineField({
148
+ name: "submitButton",
149
+ title: "Submit Button",
150
+ type: "object",
151
+ fields: [
152
+ sanity.defineField({
153
+ name: "text",
154
+ title: "Button Text",
155
+ type: "string",
156
+ initialValue: "Submit"
157
+ })
158
+ // defineField({
159
+ // name: 'position',
160
+ // title: 'Button Position',
161
+ // type: 'string',
162
+ // options: {
163
+ // list: ['left', 'center', 'right'],
164
+ // },
165
+ // initialValue: 'center',
166
+ // }),
167
+ ]
168
+ })
169
+ ]
170
+ }), validationTypesByFieldType = {
171
+ checkbox: ["minSelectedCount", "maxSelectedCount", "custom"],
172
+ color: ["custom"],
173
+ date: ["minDate", "maxDate", "custom"],
174
+ "datetime-local": ["minDate", "maxDate", "custom"],
175
+ email: ["pattern", "custom"],
176
+ file: ["maxSize", "fileType", "custom"],
177
+ hidden: ["custom"],
178
+ number: ["min", "max", "custom"],
179
+ // password: ['minLength', 'pattern', 'custom'],
180
+ radio: ["custom"],
181
+ range: ["min", "max", "step", "custom"],
182
+ select: ["custom"],
183
+ tel: ["pattern", "custom"],
184
+ text: ["minLength", "maxLength", "pattern", "custom"],
185
+ textarea: ["minLength", "maxLength", "custom"],
186
+ time: ["custom"],
187
+ url: ["pattern", "custom"]
188
+ }, formFieldType = sanity.defineType({
189
+ name: "formField",
190
+ title: "Form Field",
191
+ type: "object",
192
+ icon: lu.LuTextCursorInput,
193
+ fields: [
194
+ sanity.defineField({
195
+ name: "type",
196
+ title: "Field Type",
197
+ type: "string",
198
+ options: {
199
+ list: Object.keys(validationTypesByFieldType).map((type) => ({ title: ((fieldType) => {
200
+ switch (fieldType) {
201
+ case "datetime-local":
202
+ return "Date & Time";
203
+ case "textarea":
204
+ return "Text Area";
205
+ case "tel":
206
+ return "Phone Number";
207
+ default:
208
+ return fieldType.charAt(0).toUpperCase() + fieldType.slice(1);
209
+ }
210
+ })(type), value: type }))
211
+ }
212
+ }),
213
+ sanity.defineField({
214
+ name: "label",
215
+ title: "Field Label",
216
+ type: "string"
217
+ }),
218
+ sanity.defineField({
219
+ name: "name",
220
+ title: "Field Name",
221
+ type: "string",
222
+ description: "Must start with a letter and contain only letters, numbers, underscores, or hyphens. Must be unique within the form.",
223
+ validation: (Rule) => Rule.required().custom((name, context) => name ? /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name) ? (context.document?.fields?.map((field) => field.name) || []).filter((n) => n === name).length > 1 ? "Field name must be unique across all form fields" : [
224
+ "action",
225
+ "method",
226
+ "target",
227
+ "enctype",
228
+ "accept-charset",
229
+ "autocomplete",
230
+ "novalidate",
231
+ "rel",
232
+ "submit",
233
+ "reset"
234
+ ].includes(name.toLowerCase()) ? "This name is reserved for HTML form attributes. Please choose a different name." : !0 : "Field name must start with a letter and contain only letters, numbers, underscores, or hyphens" : "Required")
235
+ }),
236
+ sanity.defineField({
237
+ name: "required",
238
+ title: "Required",
239
+ type: "boolean",
240
+ initialValue: !1
241
+ }),
242
+ // defineField({
243
+ // name: 'validation',
244
+ // title: 'Validation Rules',
245
+ // type: 'array',
246
+ // of: [
247
+ // {
248
+ // type: 'object',
249
+ // fields: [
250
+ // defineField({
251
+ // name: 'type',
252
+ // title: 'Validation Type',
253
+ // type: 'string',
254
+ // hidden: ({parent}) => !parent?.type,
255
+ // options: {
256
+ // // TODO: I think this needs to be a custom input component?
257
+ // // list: ({parent}) => (parent?.type ? validationTypesByFieldType[parent.type] : []),
258
+ // list: [],
259
+ // },
260
+ // }),
261
+ // defineField({
262
+ // name: 'value',
263
+ // title: 'Value',
264
+ // type: 'string',
265
+ // }),
266
+ // defineField({
267
+ // name: 'message',
268
+ // title: 'Error Message',
269
+ // type: 'string',
270
+ // }),
271
+ // ],
272
+ // },
273
+ // ],
274
+ // }),
275
+ sanity.defineField({
276
+ name: "choices",
277
+ title: "Choices",
278
+ type: "array",
279
+ hidden: ({ parent }) => !["select", "radio", "checkbox"].includes(parent?.type),
280
+ of: [
281
+ {
282
+ type: "object",
283
+ fields: [
284
+ sanity.defineField({
285
+ name: "label",
286
+ title: "Label",
287
+ type: "string"
288
+ }),
289
+ sanity.defineField({
290
+ name: "value",
291
+ title: "Value",
292
+ type: "string"
293
+ })
294
+ ]
295
+ }
296
+ ]
297
+ }),
298
+ sanity.defineField({
299
+ name: "options",
300
+ title: "Field Options",
301
+ type: "object",
302
+ hidden: ({ parent }) => ["select", "radio", "checkbox", "file"].includes(parent?.type),
303
+ fields: [
304
+ sanity.defineField({
305
+ name: "placeholder",
306
+ title: "Placeholder",
307
+ type: "string"
308
+ }),
309
+ sanity.defineField({
310
+ name: "defaultValue",
311
+ title: "Default Value",
312
+ type: "string"
313
+ })
314
+ ]
315
+ })
316
+ ],
317
+ preview: {
318
+ select: {
319
+ label: "label",
320
+ name: "name",
321
+ type: "type"
322
+ },
323
+ prepare({ label, name, type }) {
324
+ return {
325
+ title: label || name,
326
+ subtitle: type
327
+ };
328
+ }
329
+ }
330
+ }), schema = {
331
+ types: [formType, formFieldType]
332
+ }, formSchema = sanity.definePlugin(() => ({
333
+ name: "form-toolkit_form-schema",
334
+ schema
335
+ // plugins: [structureTool({defaultDocumentNode})],
336
+ })), Option$1 = (option) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Card, { "data-as": "button", padding: 3, radius: 2, tone: "inherit", children: [
9
337
  /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 2, textOverflow: "ellipsis", children: option.name }),
10
338
  /* @__PURE__ */ jsxRuntime.jsx(ui.Card, { paddingTop: 2, tone: "inherit", style: { background: "inherit" }, children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: 1, textOverflow: "ellipsis", children: `ID: ${option.value}` }) })
11
339
  ] }), hubSpotInput = sanity.definePlugin((options) => ({
@@ -141,8 +469,10 @@ async function fetchMailchimpData({
141
469
  return signupForms;
142
470
  }
143
471
  const mailchimpHandler = (keys) => createHandler(() => fetchMailchimpData(keys));
472
+ exports.FormRenderer = FormRenderer;
144
473
  exports.fetchHubSpotData = fetchHubSpotData;
145
474
  exports.fetchMailchimpData = fetchMailchimpData;
475
+ exports.formSchema = formSchema;
146
476
  exports.hubSpotHandler = hubSpotHandler;
147
477
  exports.hubSpotInput = hubSpotInput;
148
478
  exports.mailchimpHandler = mailchimpHandler;