Dynamic form schema

Try it

const submitForm = createEvent()
const saveFormFx = createEffect({
handler(data) {
localStorage.setItem('form_state/2', JSON.stringify(data, null, 2))
},
})
const loadFormFx = createEffect({
handler() {
return JSON.parse(localStorage.getItem('form_state/2'))
},
})
const mainForm = createStore({}).on(loadFormFx.doneData, (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(loadFormFx.doneData, (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: saveFormFx,
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 showTooltipFx = createEffect({
handler: () => new Promise(rs => setTimeout(rs, 1500)),
})
forward({
from: addMessage,
to: showTooltipFx,
})
forward({
from: submitField,
to: addMessage.prepend(() => 'added'),
})
forward({
from: submitRemoveField,
to: addMessage.prepend(() => 'removed'),
})
forward({
from: submitForm,
to: addMessage.prepend(() => 'saved'),
})
loadFormFx.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(saveFormFx.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(showTooltipFx.pending)
const text = useStore(message)
return <span data-tooltip={text} data-visible={visible} />
}
const App = () => (
<>
<Tooltip />
<div id="app">
<Form />
<FieldForm />
<RemoveFieldForm />
</div>
</>
)
await loadFormFx()
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
}
}