Dynamic form schema

Try it

const submitForm = createEvent()
const saveForm = createEffect({
handler(data) {
localStorage.setItem(
'form_state/2',
JSON.stringify(data, null, 2)
)
}
})
const loadForm = createEffect({
handler() {
return JSON.parse(
localStorage.getItem('form_state/2')
)
}
})
const mainForm = createStore({})
.on(loadForm.done, (state, {result}) => {
let changed = false
state = {...state}
for (const key in result) {
const {value} = result[key]
if (value == null) continue
if (state[key] === value) continue
changed = true
state[key] = value
}
if (!changed) return
return state
})
const mainFormApi = createApi(mainForm, {
upsertField(state, name) {
if (name in state) return
return {...state, [name]: ''}
},
changeField(state, [name, value]) {
if (state[name] === value) return
return {...state, [name]: value}
},
addField(state, [name, value = '']) {
if (state[name] === value) return
return {...state, [name]: value}
},
deleteField(state, name) {
if (!(name in state)) return
state = {...state}
delete state[name]
return state
},
})
const types = createStore({
username: 'text',
email: 'text',
password: 'text',
})
.on(mainFormApi.addField, (state, [name, value, type]) => {
if (state[name] === type) return
return {...state, [name]: value}
})
.on(mainFormApi.deleteField, (state, name) => {
if (!(name in state)) return
state = {...state}
delete state[name]
return state
})
.on(loadForm.done, (state, {result}) => {
let changed = false
state = {...state}
for (const key in result) {
const {type} = result[key]
if (type == null) continue
if (state[key] === type) continue
changed = true
state[key] = type
}
if (!changed) return
return state
})
const fields = types.map(state => Object.keys(state))
const changeFieldInput = mainFormApi.changeField.prepend(
e => [
e.currentTarget.name,
e.currentTarget.type === 'checkbox'
? e.currentTarget.checked
: e.currentTarget.value,
]
)
const submitField = mainFormApi.addField.prepend(
e => [
e.currentTarget.fieldname.value,
e.currentTarget.fieldtype.value === 'checkbox'
? e.currentTarget.fieldvalue.checked
: e.currentTarget.fieldvalue.value,
e.currentTarget.fieldtype.value,
]
)
const submitRemoveField = mainFormApi.deleteField.prepend(
e => e.currentTarget.field.value
)
submitForm.watch(e => {
e.preventDefault()
})
submitField.watch(e => {
e.preventDefault()
e.currentTarget.reset()
})
submitRemoveField.watch(e => {
e.preventDefault()
})
sample({
source: {
values: mainForm,
types,
},
clock: merge([
submitForm,
submitField,
submitRemoveField,
]),
target: saveForm,
fn({values, types}) {
const result = {}
for (const [key, value] of Object.entries(values)) {
result[key] = {
value,
type: types[key],
}
}
return result
},
})
const addMessage = createEvent()
const message = restore(addMessage, 'done')
const showTooltip = createEffect({
handler: () => new Promise(rs => setTimeout(rs, 1500)),
})
forward({
from: addMessage,
to: showTooltip,
})
forward({
from: submitField,
to: addMessage.prepend(() => 'added'),
})
forward({
from: submitRemoveField,
to: addMessage.prepend(() => 'removed'),
})
forward({
from: submitForm,
to: addMessage.prepend(() => 'saved'),
})
loadForm.finally.watch(() => {
ReactDOM.render(
<App />,
document.getElementById('root')
)
})
function useFormField(name) {
const type = useStoreMap({
store: types,
keys: [name],
fn(state, [field]) {
if (field in state) return state[field]
return 'text'
},
})
const value = useStoreMap({
store: mainForm,
keys: [name],
fn(state, [field]) {
if (field in state) return state[field]
return ''
},
})
mainFormApi.upsertField(name)
return [value, type]
}
function Form() {
const pending = useStore(saveForm.pending)
return (
<form onSubmit={submitForm} data-form autocomplete="off">
<header>
<h4>Form</h4>
</header>
{useList(fields, name => (
<InputField name={name} />
))}
<input
type="submit"
value="save form"
disabled={pending}
/>
</form>
)
}
function InputField({name}) {
const [value, type] = useFormField(name)
let input = null
switch (type) {
case 'checkbox':
input = (
<input
id={name}
name={name}
value={name}
checked={value}
onChange={changeFieldInput}
type="checkbox"
/>
)
break
case 'text':
default:
input = (
<input
id={name}
name={name}
value={value}
onChange={changeFieldInput}
type="text"
/>
)
}
return (
<>
<label htmlFor={name} style={{display: 'block'}}>
<strong>{name}</strong>
</label>
{input}
</>
)
}
const changeFieldType = createEvent()
const fieldType = createStore('text')
.on(changeFieldType, (_, e) => e.currentTarget.value)
.reset(submitField)
function FieldForm() {
const currentFieldType = useStore(fieldType)
const fieldValue = currentFieldType === 'checkbox'
? (
<input
id="fieldvalue"
name="fieldvalue"
type="checkbox"
/>
)
: (
<input
id="fieldvalue"
name="fieldvalue"
type="text"
defaultValue=""
/>
)
return (
<form onSubmit={submitField} autocomplete="off" data-form>
<header>
<h4>Insert new field</h4>
</header>
<label htmlFor="fieldname"><strong>name</strong></label>
<input
id="fieldname"
name="fieldname"
type="text"
required
defaultValue=""
/>
<label htmlFor="fieldvalue"><strong>value</strong></label>
{fieldValue}
<label htmlFor="fieldtype"><strong>type</strong></label>
<select id="fieldtype" name="fieldtype" onChange={changeFieldType}>
<option value="text">text</option>
<option value="checkbox">checkbox</option>
</select>
<input
type="submit"
value="insert"
/>
</form>
)
}
function RemoveFieldForm() {
return (
<form onSubmit={submitRemoveField} data-form>
<header>
<h4>Remove field</h4>
</header>
<label htmlFor="field"><strong>name</strong></label>
<select id="field" name="field" required>
{useList(fields, name => (
<option value={name}>{name}</option>
))}
</select>
<input
type="submit"
value="remove"
/>
</form>
)
}
const Tooltip = () => {
const visible = useStore(showTooltip.pending)
const text = useStore(message)
return (
<span data-tooltip={text} data-visible={visible}/>
)
}
const App = () => (
<>
<Tooltip/>
<div id="app">
<Form/>
<FieldForm />
<RemoveFieldForm/>
</div>
</>
)
await loadForm()
css`
[data-tooltip]:before {
display: block;
background: white;
width: min-content;
content: attr(data-tooltip);
position: sticky;
top: 0;
left: 50%;
color: darkgreen;
font-family: sans-serif;
font-weight: 800;
font-size: 20px;
padding: 5px 5px;
transition: transform 100ms ease-out;
}
[data-tooltip][data-visible="true"]:before {
transform: translate(0px, 0.5em);
}
[data-tooltip][data-visible="false"]:before {
transform: translate(0px, -2em);
}
[data-form] {
display: contents;
}
[data-form] > header {
grid-column: 1 / span 2;
}
[data-form] > header > h4 {
margin-block-end: 0;
}
[data-form] label {
grid-column: 1;
justify-self: end;
}
[data-form] input:not([type="submit"]),
[data-form] select {
grid-column: 2;
}
[data-form] input[type="submit"] {
grid-column: 2;
justify-self: end;
width: fit-content;
}
#app {
width: min-content;
display: grid;
grid-column-gap: 5px;
grid-row-gap: 8px;
grid-template-columns: repeat(2, 3fr);
}
`
function css(tags, ...attrs) {
const value = style(tags, ...attrs)
const node = document.createElement('style')
node.id = 'insertedStyle'
node.appendChild(document.createTextNode(value))
const sheet = document.getElementById('insertedStyle')
if (sheet) {
sheet.disabled = true
sheet.parentNode.removeChild(sheet)
}
document.head.appendChild(node)
function style(tags, ...attrs) {
if (tags.length === 0) return ''
let result = ' ' + tags[0]
for (let i = 0; i < attrs.length; i++) {
result += attrs[i]
result += tags[i + 1]
}
return result
}
}