@sanity/form-toolkit 1.1.0 → 1.2.1

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.
@@ -0,0 +1,113 @@
1
+ import type {ChangeEvent, FC, LegacyRef} from 'react'
2
+
3
+ import type {FieldComponentProps} from './types'
4
+
5
+ export const DefaultField: FC<FieldComponentProps> = ({field, fieldState, error}) => {
6
+ const {type, label, name, options = {}, choices = []} = field
7
+ if (!type || !name) return null
8
+
9
+ const {value, onChange, onBlur, ref} = fieldState
10
+
11
+ const handleChange = (
12
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
13
+ ) => {
14
+ onChange(e.target.value)
15
+ }
16
+
17
+ const handleCheckboxChange = (e: ChangeEvent<HTMLInputElement>, choiceValue: string) => {
18
+ if (Array.isArray(value)) {
19
+ const newValue = e.target.checked
20
+ ? [...value, choiceValue]
21
+ : value.filter((v: string) => v !== choiceValue)
22
+ onChange(newValue)
23
+ } else {
24
+ onChange(e.target.checked ? choiceValue : '')
25
+ }
26
+ }
27
+
28
+ const renderInput = () => {
29
+ switch (type) {
30
+ case 'textarea':
31
+ return (
32
+ <textarea
33
+ ref={ref as LegacyRef<HTMLTextAreaElement>}
34
+ name={name}
35
+ value={value ?? ''}
36
+ onChange={handleChange}
37
+ onBlur={onBlur}
38
+ placeholder={options.placeholder}
39
+ />
40
+ )
41
+
42
+ case 'select':
43
+ return (
44
+ <select
45
+ ref={ref as LegacyRef<HTMLSelectElement>}
46
+ name={name}
47
+ value={value ?? ''}
48
+ onChange={handleChange}
49
+ onBlur={onBlur}
50
+ >
51
+ {choices?.map((choice, i) => (
52
+ <option key={i} value={choice.value}>
53
+ {choice.label}
54
+ </option>
55
+ ))}
56
+ </select>
57
+ )
58
+
59
+ case 'radio':
60
+ return choices?.map((choice, i) => (
61
+ <label key={i}>
62
+ <input
63
+ type="radio"
64
+ name={name}
65
+ ref={ref as LegacyRef<HTMLInputElement>}
66
+ value={choice.value}
67
+ checked={value === choice.value}
68
+ onChange={handleChange}
69
+ onBlur={onBlur}
70
+ />
71
+ {choice.label}
72
+ </label>
73
+ ))
74
+
75
+ case 'checkbox':
76
+ return choices?.map((choice, i) => (
77
+ <label key={i}>
78
+ <input
79
+ type="checkbox"
80
+ name={name}
81
+ ref={ref as LegacyRef<HTMLInputElement>}
82
+ value={choice.value}
83
+ checked={Array.isArray(value) ? value.includes(choice.value) : value === choice.value}
84
+ onChange={(e) => handleCheckboxChange(e, choice.value)}
85
+ onBlur={onBlur}
86
+ />
87
+ {choice.label}
88
+ </label>
89
+ ))
90
+
91
+ default:
92
+ return (
93
+ <input
94
+ type={type}
95
+ ref={ref as LegacyRef<HTMLInputElement>}
96
+ name={name}
97
+ value={value ?? options.defaultValue ?? ''}
98
+ onChange={handleChange}
99
+ onBlur={onBlur}
100
+ placeholder={options.placeholder}
101
+ />
102
+ )
103
+ }
104
+ }
105
+
106
+ return (
107
+ <>
108
+ {label && type != 'hidden' && <label htmlFor={name}>{label}</label>}
109
+ {renderInput()}
110
+ {error && <span className="error">{error}</span>}
111
+ </>
112
+ )
113
+ }
@@ -0,0 +1,58 @@
1
+ import type {ComponentType, FC, HTMLProps} from 'react'
2
+
3
+ import {DefaultField} from './default-field'
4
+ import type {FieldComponentProps, FieldState, FormDataProps, FormField} from './types'
5
+
6
+ interface FormRendererProps extends HTMLProps<HTMLFormElement> {
7
+ formData?: FormDataProps
8
+ // Function to get field state for a given field name
9
+ getFieldState?: (fieldName: string) => FieldState
10
+ // Function to get field error for a given field name
11
+ getFieldError?: (fieldName: string) => string | undefined
12
+ // Override default field components
13
+ fieldComponents?: Record<string, ComponentType<FieldComponentProps>>
14
+ }
15
+
16
+ export const FormRenderer: FC<FormRendererProps> = (props) => {
17
+ const {
18
+ formData,
19
+ getFieldState = (name) => ({
20
+ value: undefined,
21
+ onChange: () => {},
22
+ name, // Pass name to field for native form handling
23
+ }),
24
+ getFieldError,
25
+ fieldComponents = {},
26
+ children,
27
+ } = props
28
+ const renderField = (field: FormField) => {
29
+ const CustomComponent = fieldComponents[field.type]
30
+ const fieldState = getFieldState(field.name)
31
+ const error = getFieldError?.(field.name)
32
+
33
+ if (CustomComponent) {
34
+ return <CustomComponent field={field} fieldState={fieldState} error={error} />
35
+ }
36
+
37
+ return <DefaultField field={field} fieldState={fieldState} error={error} />
38
+ }
39
+ const elProps = Object.assign({}, props)
40
+ delete elProps.formData
41
+ delete elProps.getFieldState
42
+ delete elProps.getFieldError
43
+ delete elProps.fieldComponents
44
+
45
+ return (
46
+ <form {...elProps} id={elProps.id ?? formData?.id?.current}>
47
+ {formData?.fields?.map((field) => (
48
+ <div key={field._key} className="form-field">
49
+ {renderField(field)}
50
+ </div>
51
+ ))}
52
+
53
+ {children}
54
+
55
+ <button type="submit">{formData?.submitButton?.text || 'Submit'}</button>
56
+ </form>
57
+ )
58
+ }
@@ -0,0 +1,53 @@
1
+ // types.ts
2
+ export type ValidationRule = {
3
+ type: string
4
+ value: string
5
+ message: string
6
+ }
7
+
8
+ export type FieldChoice = {
9
+ label: string
10
+ value: string
11
+ }
12
+
13
+ export type FieldOptions = {
14
+ placeholder?: string
15
+ defaultValue?: string
16
+ }
17
+
18
+ export type FormField = {
19
+ type: string
20
+ label?: string
21
+ name: string
22
+ required: boolean
23
+ validation?: ValidationRule[]
24
+ options?: FieldOptions
25
+ choices?: FieldChoice[]
26
+ _key: string
27
+ }
28
+
29
+ export type FormDataProps = {
30
+ title: string
31
+ id: {
32
+ current: string
33
+ }
34
+ fields?: FormField[]
35
+
36
+ submitButton?: {
37
+ text: string
38
+ position: 'left' | 'center' | 'right'
39
+ }
40
+ }
41
+
42
+ export interface FieldState {
43
+ value?: string | number | readonly string[]
44
+ onChange: (value: unknown) => void
45
+ onBlur?: () => void
46
+ ref?: unknown
47
+ }
48
+
49
+ export interface FieldComponentProps {
50
+ field: FormField
51
+ fieldState: FieldState
52
+ error?: string
53
+ }
@@ -1,27 +1,28 @@
1
1
  import {definePlugin} from 'sanity'
2
+ // import {structureTool} from 'sanity/structure'
2
3
 
4
+ import {FormRenderer} from './components/form-renderer'
3
5
  import {schema} from './schema-types'
4
- interface FormSchemaConfig {
5
- /* nothing here yet */
6
- }
6
+ // import {defaultDocumentNode} from './structure'
7
7
 
8
8
  /**
9
9
  * Usage in `sanity.config.ts` (or .js)
10
10
  *
11
11
  * ```ts
12
12
  * import {defineConfig} from 'sanity'
13
- * import {formiumInput} from 'sanity-plugin-form-toolkit'
13
+ * import {formSchema} from '@sanity/form-toolkit'
14
14
  *
15
15
  * export default defineConfig({
16
16
  * // ...
17
- * plugins: [formiumInput()],
17
+ * plugins: [formSchema()],
18
18
  * })
19
19
  * ```
20
20
  */
21
- // Is Formium dead? All attempts to use the API come back with an expired cert https://github.com/formium/formium/issues/77
22
- export const formSchema = definePlugin<FormSchemaConfig | void>(() => {
21
+ export {FormRenderer}
22
+ export const formSchema = definePlugin(() => {
23
23
  return {
24
- name: 'sanity-plugin-form-toolkit_form-schema',
24
+ name: 'form-toolkit_form-schema',
25
25
  schema,
26
+ // plugins: [structureTool({defaultDocumentNode})],
26
27
  }
27
28
  })
@@ -1,146 +1,210 @@
1
- import {
2
- CalendarIcon,
3
- ClockIcon,
4
- ColorWheelIcon,
5
- DocumentIcon,
6
- EarthGlobeIcon,
7
- EnvelopeIcon,
8
- HashIcon,
9
- NumberIcon,
10
- TextIcon,
11
- } from '@sanity/icons'
1
+ import {LuTextCursorInput} from 'react-icons/lu'
12
2
  import {defineField, defineType} from 'sanity'
13
3
 
4
+ interface ValidationContextDocument {
5
+ fields?: Array<{
6
+ name: string
7
+ type?: string
8
+ }>
9
+ }
10
+ // Validation options by field type
11
+ export const validationTypesByFieldType = {
12
+ checkbox: ['minSelectedCount', 'maxSelectedCount', 'custom'],
13
+ color: ['custom'],
14
+ date: ['minDate', 'maxDate', 'custom'],
15
+ 'datetime-local': ['minDate', 'maxDate', 'custom'],
16
+ email: ['pattern', 'custom'],
17
+ file: ['maxSize', 'fileType', 'custom'],
18
+ hidden: ['custom'],
19
+ number: ['min', 'max', 'custom'],
20
+ // password: ['minLength', 'pattern', 'custom'],
21
+ radio: ['custom'],
22
+ range: ['min', 'max', 'step', 'custom'],
23
+ select: ['custom'],
24
+ tel: ['pattern', 'custom'],
25
+ text: ['minLength', 'maxLength', 'pattern', 'custom'],
26
+ textarea: ['minLength', 'maxLength', 'custom'],
27
+ time: ['custom'],
28
+ url: ['pattern', 'custom'],
29
+ }
14
30
  export const formFieldType = defineType({
15
31
  name: 'formField',
32
+ title: 'Form Field',
16
33
  type: 'object',
34
+ icon: LuTextCursorInput,
17
35
  fields: [
18
36
  defineField({
19
- name: 'label',
37
+ name: 'type',
38
+ title: 'Field Type',
20
39
  type: 'string',
21
- group: 'info',
22
- }),
23
- defineField({
24
- name: 'name',
25
- type: 'slug',
26
- group: 'info',
27
- validation: (rule) => rule.required(),
28
40
  options: {
29
- source: 'label',
41
+ list: Object.keys(validationTypesByFieldType).map((type) => {
42
+ const title = (fieldType: string) => {
43
+ switch (fieldType) {
44
+ case 'datetime-local':
45
+ return 'Date & Time'
46
+ case 'textarea':
47
+ return 'Text Area'
48
+ case 'tel':
49
+ return 'Phone Number'
50
+ default:
51
+ return fieldType.charAt(0).toUpperCase() + fieldType.slice(1)
52
+ }
53
+ }
54
+ return {title: title(type), value: type}
55
+ }),
30
56
  },
31
57
  }),
32
58
  defineField({
33
- name: 'type',
59
+ name: 'label',
60
+ title: 'Field Label',
34
61
  type: 'string',
35
- group: 'info',
36
- validation: (rule) => rule.required(),
37
- options: {
38
- list: [
39
- {value: 'color', title: 'Color'},
40
- {value: 'date', title: 'Date'},
41
- {value: 'datetime-local', title: 'Date-time'},
42
- 'email',
43
- 'file',
44
- {value: 'month', title: 'Month & year'},
45
- 'number',
46
- {value: 'tel', title: 'Telephone'},
47
- 'text',
48
- 'time',
49
- 'range',
50
- {value: 'url', title: 'URL'},
51
- 'week',
52
- ],
53
- },
54
62
  }),
55
63
  defineField({
56
- group: 'validation',
57
- name: 'required',
58
- type: 'boolean',
59
- hidden: ({parent}) => {
60
- const unallowedTypes = ['hidden', 'range', 'color']
64
+ name: 'name',
65
+ title: 'Field Name',
66
+ type: 'string',
67
+ description:
68
+ 'Must start with a letter and contain only letters, numbers, underscores, or hyphens. Must be unique within the form.',
69
+ validation: (Rule) =>
70
+ Rule.required().custom((name, context) => {
71
+ if (!name) {
72
+ return 'Required'
73
+ }
74
+ // Check format (HTML ID/name rules)
75
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
76
+ return 'Field name must start with a letter and contain only letters, numbers, underscores, or hyphens'
77
+ }
61
78
 
62
- return unallowedTypes.includes(parent.type)
63
- },
64
- }),
65
- defineField({
66
- group: 'validation',
67
- name: 'max',
68
- type: 'number',
69
- hidden: ({parent}) => {
70
- const allowedTypes = ['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range']
79
+ // Check uniqueness across all fields
80
+ const doc = context.document as ValidationContextDocument
81
+ const allFieldNames = doc?.fields?.map((field) => field.name) || []
71
82
 
72
- return !allowedTypes.includes(parent.type)
73
- },
74
- }),
75
- defineField({
76
- group: 'validation',
77
- name: 'min',
78
- type: 'number',
79
- hidden: ({parent}) => {
80
- const allowedTypes = ['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range']
83
+ // Count occurrences of this name
84
+ const nameCount = allFieldNames.filter((n) => n === name).length
81
85
 
82
- return !allowedTypes.includes(parent.type)
83
- },
86
+ // If we find more than one occurrence (including current field), it's not unique
87
+ if (nameCount > 1) {
88
+ return 'Field name must be unique across all form fields'
89
+ }
90
+
91
+ // Check for reserved HTML form attributes
92
+ const reservedNames = [
93
+ 'action',
94
+ 'method',
95
+ 'target',
96
+ 'enctype',
97
+ 'accept-charset',
98
+ 'autocomplete',
99
+ 'novalidate',
100
+ 'rel',
101
+ 'submit',
102
+ 'reset',
103
+ ]
104
+ if (reservedNames.includes(name.toLowerCase())) {
105
+ return 'This name is reserved for HTML form attributes. Please choose a different name.'
106
+ }
107
+
108
+ return true
109
+ }),
84
110
  }),
85
111
  defineField({
86
- group: 'validation',
87
- name: 'step',
88
- type: 'number',
89
- hidden: ({parent}) => {
90
- const allowedTypes = ['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range']
91
-
92
- return !allowedTypes.includes(parent.type)
93
- },
112
+ name: 'required',
113
+ title: 'Required',
114
+ type: 'boolean',
115
+ initialValue: false,
94
116
  }),
117
+ // defineField({
118
+ // name: 'validation',
119
+ // title: 'Validation Rules',
120
+ // type: 'array',
121
+ // of: [
122
+ // {
123
+ // type: 'object',
124
+ // fields: [
125
+ // defineField({
126
+ // name: 'type',
127
+ // title: 'Validation Type',
128
+ // type: 'string',
129
+
130
+ // hidden: ({parent}) => !parent?.type,
131
+ // options: {
132
+ // // TODO: I think this needs to be a custom input component?
133
+ // // list: ({parent}) => (parent?.type ? validationTypesByFieldType[parent.type] : []),
134
+ // list: [],
135
+ // },
136
+ // }),
137
+ // defineField({
138
+ // name: 'value',
139
+ // title: 'Value',
140
+ // type: 'string',
141
+ // }),
142
+ // defineField({
143
+ // name: 'message',
144
+ // title: 'Error Message',
145
+ // type: 'string',
146
+ // }),
147
+ // ],
148
+ // },
149
+ // ],
150
+ // }),
95
151
  defineField({
96
- group: 'validation',
97
- name: 'maxlength',
98
- title: 'Max length',
99
- type: 'number',
152
+ name: 'choices',
153
+ title: 'Choices',
154
+ type: 'array',
100
155
  hidden: ({parent}) => {
101
- const allowedTypes = ['text', 'search', 'url', 'tel', 'email', 'password']
102
-
103
- return !allowedTypes.includes(parent.type)
156
+ return !['select', 'radio', 'checkbox'].includes(parent?.type)
104
157
  },
158
+ of: [
159
+ {
160
+ type: 'object',
161
+ fields: [
162
+ defineField({
163
+ name: 'label',
164
+ title: 'Label',
165
+ type: 'string',
166
+ }),
167
+ defineField({
168
+ name: 'value',
169
+ title: 'Value',
170
+ type: 'string',
171
+ }),
172
+ ],
173
+ },
174
+ ],
105
175
  }),
106
176
  defineField({
107
- group: 'validation',
108
- name: 'minlength',
109
- title: 'Min length',
110
- type: 'number',
177
+ name: 'options',
178
+ title: 'Field Options',
179
+ type: 'object',
111
180
  hidden: ({parent}) => {
112
- const allowedTypes = ['text', 'search', 'url', 'tel', 'email', 'password']
113
-
114
- return !allowedTypes.includes(parent.type)
181
+ return ['select', 'radio', 'checkbox', 'file'].includes(parent?.type)
115
182
  },
183
+ fields: [
184
+ defineField({
185
+ name: 'placeholder',
186
+ title: 'Placeholder',
187
+ type: 'string',
188
+ }),
189
+ defineField({
190
+ name: 'defaultValue',
191
+ title: 'Default Value',
192
+ type: 'string',
193
+ }),
194
+ ],
116
195
  }),
117
196
  ],
118
- groups: [{name: 'info'}, {name: 'validation'}],
119
197
  preview: {
120
198
  select: {
121
- title: 'label',
122
- subtitle: 'type',
199
+ label: 'label',
200
+ name: 'name',
201
+ type: 'type',
123
202
  },
124
- prepare: ({title, subtitle}) => {
125
- const icon: Record<
126
- string,
127
- typeof TextIcon | typeof NumberIcon | typeof CalendarIcon | typeof ClockIcon
128
- > = {
129
- text: TextIcon,
130
- number: NumberIcon,
131
- date: CalendarIcon,
132
- 'datetime-local': CalendarIcon,
133
- month: CalendarIcon,
134
- time: ClockIcon,
135
- color: ColorWheelIcon,
136
- email: EnvelopeIcon,
137
- url: EarthGlobeIcon,
138
- file: DocumentIcon,
139
- tel: HashIcon,
140
- week: CalendarIcon,
141
- range: NumberIcon,
203
+ prepare({label, name, type}) {
204
+ return {
205
+ title: label || name,
206
+ subtitle: type,
142
207
  }
143
- return {title, subtitle, media: icon[subtitle]}
144
208
  },
145
209
  },
146
210
  })
@@ -1,13 +1,55 @@
1
+ import {FaWpforms} from 'react-icons/fa'
1
2
  import {defineField, defineType} from 'sanity'
2
3
 
3
4
  export const formType = defineType({
4
5
  name: 'form',
5
- type: 'object',
6
+ title: 'Form',
7
+ type: 'document',
8
+ icon: FaWpforms,
6
9
  fields: [
10
+ defineField({
11
+ name: 'title',
12
+ title: 'Form Title',
13
+ type: 'string',
14
+ description: 'Internal title for the form',
15
+ validation: (Rule) => Rule.required(),
16
+ }),
17
+ defineField({
18
+ name: 'id',
19
+ title: 'Form ID',
20
+ type: 'slug',
21
+ options: {
22
+ source: 'title',
23
+ },
24
+ // validation: (Rule) => Rule.required(),
25
+ }),
7
26
  defineField({
8
27
  name: 'fields',
28
+ title: 'Form Fields',
9
29
  type: 'array',
10
30
  of: [{type: 'formField'}],
11
31
  }),
32
+ defineField({
33
+ name: 'submitButton',
34
+ title: 'Submit Button',
35
+ type: 'object',
36
+ fields: [
37
+ defineField({
38
+ name: 'text',
39
+ title: 'Button Text',
40
+ type: 'string',
41
+ initialValue: 'Submit',
42
+ }),
43
+ // defineField({
44
+ // name: 'position',
45
+ // title: 'Button Position',
46
+ // type: 'string',
47
+ // options: {
48
+ // list: ['left', 'center', 'right'],
49
+ // },
50
+ // initialValue: 'center',
51
+ // }),
52
+ ],
53
+ }),
12
54
  ],
13
55
  })
@@ -4,5 +4,5 @@ import {formType} from './form'
4
4
  import {formFieldType} from './form-field'
5
5
 
6
6
  export const schema: {types: SchemaTypeDefinition[]} = {
7
- types: [formFieldType, formType],
7
+ types: [formType, formFieldType],
8
8
  }