Form in three modes
Problem: an item is created in a wizard, edited in a modal, and edited again on its own page, where each change saves at once. Three forms drift apart: a field added to one is missing from the others, and the validation differs.
Pattern: one form component holds the fields, split into tabs, and two props pick the mode:
| Mode | Props | What changes |
|---|---|---|
| Create, in a modal | no plantId | The tabs become steps without tab buttons (stepper, no-header), bringing their own title, progress line and Back, Next and Add plant (stepper-controls). Next checks the step's fields. The whole model is sent once, at the end (full-payload). |
| Edit, in a modal | plantId | The tabs get their buttons. Only the changed fields are sent, on Save. |
| Edit, on a page | plantId, async | Every tab is shown under its title (flat). Each field saves when it changes. |
The pieces:
| Piece | What it is |
|---|---|
| A model | createRestModelApi with an item query that loads the plant, an update action on it, and a create action on the list. |
| The form | A WUniform that loads the model with useQueryFn, around a WTabs, inside a WModalWrapper that holds the title and the buttons when editing. |
| The modal | The form itself, opened with Modal.add. The modal's frame places the title and the buttons. |
| The page | The form with async. Outside an overlay, WModalWrapper lays it out in the flow of the page, without a title or buttons. |
<template>
<div class="mb-8 flex flex-wrap items-end gap-4">
<WSelectSingle
v-model="plantId"
:options="queryPlants.data.value ?? []"
:value-getter="item => item.id"
:search-fn="(item, search) => item.name.toLowerCase().includes(search)"
:loading="queryPlants.isLoading.value"
title="Plant on the page"
class="min-w-60 grow"
no-margin
>
<template #option="{option}">
{{ option?.name }}
</template>
</WSelectSingle>
<WButton
:semantic-type="SemanticType.SECONDARY"
:disabled="!isId(plantId)"
@click="openModal(plantId)"
>
Edit in a modal
</WButton>
<WButton @click="openModal()">
<IconAdd class="square-4" /> Add plant
</WButton>
</div>
<!-- The same form on the page: each field saves as it changes. -->
<PlantForm
v-if="isId(plantId)"
:plant-id="plantId"
async
/>
</template>
<script lang="ts" setup>
import type {Plant} from '../plant-list/models/Plant'
import {markRaw, ref} from 'vue'
import {Modal} from 'eco-vue-js/dist/utils/Modal'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'
import {isId} from 'eco-vue-js/dist/utils/utils'
import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import WSelectSingle from 'eco-vue-js/dist/components/Select/WSelectSingle.vue'
import IconAdd from 'eco-vue-js/dist/assets/icons/IconAdd'
import PlantForm from './PlantForm.vue'
import {plantModelApi} from '../plant-list/api/Plant'
const queryPlants = plantModelApi.list.use()
const plantId = ref<number | undefined>(1)
/** Opens the same form as a modal: it edits the plant, or walks through adding one when there is no id. A new plant opens on the page. */
const openModal = (id?: number) => {
Modal.add(markRaw(PlantForm), {
plantId: id,
onSaved: (plant: Plant) => plantId.value = plant.id,
})
}
</script>Pick a plant, change a field and leave it: it saves on its own. Open the same plant in the modal, change it there and save, and the page shows the change. In the tasks, add a row: the list is saved as a whole, with its own Save. Then try Add plant with the fields left empty. The data is in memory here, behind the same model API a real endpoint would use.
The code
Model
The form uses the plant model from List with fields, with two more pieces:
- A
listquery with acreateaction. The action callssetto cache the new plant for its item query, andinvalidateto refetch the lists it may now appear in. - A payload type. The API takes the caretaker by id, as most APIs take a related item, so
updateandcreateacceptPlantPayloadrather thanPartial<Plant>.
list: {
scope: 'list',
dataType: [] as Plant[],
queryFn: () => apiClient.get<Plant[]>(PLANT_PATH).then(response => response.data),
actions: {
create: ({set, invalidate}, payload: PlantPayload) => apiClient
.post<Plant>(PLANT_PATH, payload)
.then(response => {
set(response.data)
invalidate()
return response.data
}),
},
},The form
One WUniform holds the model in every mode:
useQueryFnandqueryParamsload the plant. Without aplantIdthe params are0, which the item query'sisIdguard holds back, so nothing loads and the form starts blank.initDataturns the loaded plant into the model the fields edit. It gets{}until the plant loads, and when creating, so read every key with a default. This is also where the API's shape and the form's differ: the plant has a caretaker object, the form edits its id.apiMethodcreates the plant or updates it.full-payloadis set only when creating, so a create sends every field and an update only the changed ones.asynccomes straight from the prop. Each field then saves on its own when it changes.
Inside it, one WTabs takes the layout from the mode: stepper and no-header when creating, flat on a page, plain tabs when editing in a modal. Every field is written once and shows up in all three.
A stepper checks the fields of a step before it moves on. Invalid ones show their errors, a warning lists them, and the step stays open, so required fields need nothing beyond required. A submit checks every step and opens the first one with an error.
The tasks are an array, edited as a nested form with one row per item: modelValueList gives each row a stable key, and select and unselect add and remove rows. On a page, saving each keystroke of a half-typed row would be wrong, so the task list is a form of its own there:
v-bind="{...scope, async: false}"turns off saving on change, for this form only.init-datamakes it keep its own copy of the tasks. Itsapi-methodonly returns the value, and@successwrites it into the plant's model, which then saves the field as any other.- In a modal or a wizard, both are left unset, and the tasks are a part of the plant's form.
What the form shows outside its fields comes from two places:
- Creating, the stepper brings it with
stepper-controls: the step's title, a progress line under it, and Close or Back with Next or Add plant (submit-text). Add plant checks the last step and submits theWUniformaround it. - Editing,
WModalWrapperholds the plant's name as the title, and Cancel and Save, built from theWUniform's template ref. - With
async, on a page, there are neither: each field saves on its own.
The form does not know where it is shown. The frame it opens in — a modal, or a dropdown and a bottom sheet for a small form — takes the title and the buttons from WModalWrapper and places them, pads the fields, and scrolls them between. On a page there is no frame, and WModalWrapper lays everything out in place. The WUniform tells the frame it is saving, which keeps it open, and that it has unsaved changes, so a modal asks before closing.
<template>
<!--
The form brings its own title and buttons: the frame it opens in — a modal, a dropdown, a bottom sheet — places them. On a page, there are none.
Creating, the stepper brings its own instead: the step's title, the progress, and Back, Next and Add plant.
-->
<WModalWrapper
maximized
class="sm:w-modal-wrapper-w-160"
>
<template
v-if="!async && !isCreate"
#title
>
{{ formRef?.modelValue.name || 'Plant' }}
</template>
<WUniform
ref="form"
:use-query-fn="plantModelApi.item.use"
:query-params="plantId ?? 0"
:init-data="getModel"
:api-method="save"
:full-payload="!isId(plantId)"
:async="async"
:readonly="readonly"
@success="$emit('saved', $event); async || $emit('close:modal')"
>
<template #default="scope">
<!-- One set of tabs, three layouts: steps when creating, tabs when editing, every tab under its title on a page. -->
<WTabs
:stepper="isCreate"
:no-header="isCreate"
:flat="async"
submit-text="Add plant"
stepper-controls
>
<WTabsItem
title="Plant"
name="plant"
>
<div class="py-2">
<WUniform
v-bind="scope"
field="name"
title="Name"
required
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
:max-length="100"
:autofocus="isCreate"
required
/>
</template>
</WUniform>
<WUniform
v-bind="scope"
field="species"
title="Species"
required
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
:max-length="100"
placeholder="Monstera deliciosa"
required
/>
</template>
</WUniform>
<WUniform
v-bind="scope"
field="kind"
title="Kind"
required
>
<template #field="scopeField">
<WButtonGroup
v-bind="scopeField"
:list="Object.values(Kind)"
wrap
>
<template #option="{option}">
<component
:is="kindDisplay[option].icon"
class="square-[1.25em]"
/>
{{ kindDisplay[option].name }}
</template>
</WButtonGroup>
</template>
</WUniform>
<WUniform
v-bind="scope"
field="height"
title="Height, cm"
:validate="validatePositive"
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
type="number"
/>
</template>
</WUniform>
</div>
</WTabsItem>
<WTabsItem
title="Care"
name="care"
>
<div class="py-2">
<WInfoCard class="mb-6">
Light and watering are for a typical room — set them for where the plant stands.
</WInfoCard>
<WUniform
v-bind="scope"
field="light"
title="Light"
>
<template #field="scopeField">
<WButtonGroup
v-bind="scopeField"
:list="Object.values(Light)"
wrap
>
<template #option="{option}">
<component
:is="lightDisplay[option].icon"
class="square-[1.25em]"
/>
{{ lightDisplay[option].name }}
</template>
</WButtonGroup>
</template>
</WUniform>
<div class="grid grid-cols-2 gap-4">
<WUniform
v-bind="scope"
field="water"
title="Water per watering, ml"
:validate="validatePositive"
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
type="number"
/>
</template>
</WUniform>
<WUniform
v-bind="scope"
field="humidity"
title="Humidity, %"
:validate="validatePercent"
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
type="number"
/>
</template>
</WUniform>
</div>
<WUniform
v-bind="scope"
field="caretaker"
title="Caretaker"
required
>
<template #field="scopeField">
<WSelectSingle
v-bind="scopeField"
:options="gardeners"
:value-getter="item => item.id"
:search-fn="(item, search) => item.name.toLowerCase().includes(search)"
:option-component="markRaw(OptionGardener)"
placeholder="Search caretakers"
required
/>
</template>
</WUniform>
</div>
</WTabsItem>
<WTabsItem
title="Tasks"
name="tasks"
>
<div class="py-2">
<!--
The task list is saved as a whole. On the page it is a form of its own, with Save and Cancel,
so a half-typed task is not sent; elsewhere it is a part of the form it is in.
-->
<WUniform
v-bind="{...scope, async: false}"
field="tasks"
:init-data="async ? copyTasks : undefined"
:api-method="async ? (value => value as Task[]) : undefined"
full-payload
@success="scope.updateModelValueInner($event, ['tasks'] as const)"
>
<template #default="scopeTasks">
<WUniform
v-for="(item, key, index) in scopeTasks.modelValueList"
:key="key"
v-bind="scopeTasks"
:field="index"
>
<template #default="scopeTask">
<div class="grid grid-cols-[1fr_10rem_auto] items-end gap-2">
<WUniform
v-bind="scopeTask"
field="title"
title="Task"
required
>
<template #field="scopeField">
<WInput
v-bind="scopeField"
:hide-title="index !== 0"
required
/>
</template>
</WUniform>
<WUniform
v-bind="scopeTask"
field="due"
title="Due"
required
>
<template #field="scopeField">
<WInputDate
v-bind="scopeField"
:hide-title="index !== 0"
required
/>
</template>
</WUniform>
<WButton
:semantic-type="SemanticType.SECONDARY"
:disabled="scopeTasks.readonly || scopeTasks.submitting || scopeTasks.skeleton"
class="mb-4 w-(--w-input-height)"
@click="scopeTasks.unselect(item)"
>
<IconClose class="square-4" />
</WButton>
</div>
</template>
</WUniform>
<div class="flex flex-wrap gap-4">
<WButton
v-if="!scopeTasks.readonly"
:semantic-type="SemanticType.SECONDARY"
:disabled="scopeTasks.skeleton || scopeTasks.submitting"
@click="scopeTasks.select({title: '', due: getStartOfDay()})"
>
<IconAdd class="square-4" /> Add task
</WButton>
<template v-if="async && scopeTasks.hasChanges">
<WButton
:semantic-type="SemanticType.SECONDARY"
:disabled="scopeTasks.submitting"
class="ml-auto"
@click="scopeTasks.initModel()"
>
Cancel
</WButton>
<WButton
:loading="scopeTasks.submitting"
@click="scopeTasks.submit?.()"
>
Save tasks
</WButton>
</template>
</div>
</template>
</WUniform>
</div>
</WTabsItem>
</WTabs>
</template>
</WUniform>
<!-- Editing gets Cancel and Save on every tab. -->
<template
v-if="!async && !isCreate"
#actions
>
<WButton
:disabled="formRef?.submitting"
:semantic-type="SemanticType.SECONDARY"
class="w-full"
@click="$emit('close:modal')"
>
{{ formRef?.hasChanges ? 'Cancel' : 'Close' }}
</WButton>
<WButton
:disabled="!formRef?.hasChanges"
:loading="formRef?.submitting"
class="w-full"
@click="formRef?.submit?.()"
>
Save
</WButton>
</template>
</WModalWrapper>
</template>
<script lang="ts" setup>
import {computed, markRaw, useTemplateRef} from 'vue'
import {SemanticType} from 'eco-vue-js/dist/utils/SemanticType'
import {getStartOfDay} from 'eco-vue-js/dist/utils/dateTime'
import {isId} from 'eco-vue-js/dist/utils/utils'
import WButton from 'eco-vue-js/dist/components/Button/WButton.vue'
import WButtonGroup from 'eco-vue-js/dist/components/Button/WButtonGroup.vue'
import WInfoCard from 'eco-vue-js/dist/components/InfoCard/WInfoCard.vue'
import WInput from 'eco-vue-js/dist/components/Input/WInput.vue'
import WInputDate from 'eco-vue-js/dist/components/Input/WInputDate.vue'
import WModalWrapper from 'eco-vue-js/dist/components/Modal/WModalWrapper.vue'
import WSelectSingle from 'eco-vue-js/dist/components/Select/WSelectSingle.vue'
import WTabs from 'eco-vue-js/dist/components/Tabs/WTabs.vue'
import WTabsItem from 'eco-vue-js/dist/components/Tabs/WTabsItem.vue'
import WUniform from 'eco-vue-js/dist/components/Uniform/WUniform.vue'
import IconAdd from 'eco-vue-js/dist/assets/icons/IconAdd'
import IconClose from 'eco-vue-js/dist/assets/icons/IconClose'
import {gardeners} from '../../shared/Gardener'
import OptionGardener from '../../shared/OptionGardener.vue'
import {type PlantPayload, plantModelApi} from '../plant-list/api/Plant'
import {Kind, Light, type Plant, type Task} from '../plant-list/models/Plant'
import {kindDisplay, lightDisplay} from '../plant-list/models/PlantDisplay'
/** What the form edits: the plant's own fields, with the caretaker by id, as the API takes it. */
type PlantFormData = Pick<Plant, 'name' | 'species' | 'kind' | 'height' | 'light' | 'water' | 'humidity' | 'tasks'> & {
caretaker: number | undefined
}
const props = defineProps<{
/** The plant to edit. Without it, the form creates a plant. */
plantId?: number
/** Saves each field as it changes, for editing on a page. */
async?: boolean
readonly?: boolean
}>()
defineEmits<{
(e: 'saved', value: Plant): void
(e: 'close:modal'): void
}>()
const formRef = useTemplateRef<ComponentInstance<typeof WUniform<PlantFormData, number, undefined, Plant, PlantFormData>>>('form')
const isCreate = computed(() => !props.async && !isId(props.plantId))
/** Builds the editable model from the loaded plant. It gets `{}` until the plant loads, and when creating. */
const getModel = (plant: Partial<Plant>): PlantFormData => ({
name: plant.name ?? '',
species: plant.species ?? '',
kind: plant.kind ?? Kind.TROPICAL,
height: plant.height ?? 10,
light: plant.light ?? Light.BRIGHT,
water: plant.water ?? 200,
humidity: plant.humidity ?? 50,
caretaker: plant.caretaker?.id,
tasks: copyTasks(plant.tasks),
})
const copyTasks = (tasks: Task[] | undefined): Task[] => tasks?.map(task => ({...task})) ?? []
/** Creates the plant with the whole model, or updates it with the changed fields only. */
const save = (payload: Partial<PlantFormData>) => {
return isId(props.plantId)
? plantModelApi.item.actions.update(props.plantId, payload as PlantPayload)
: plantModelApi.list.actions.create(payload as PlantPayload)
}
const validatePositive = (value: unknown) => typeof value === 'number' && value < 0 ? 'Must not be negative' : undefined
const validatePercent = (value: unknown) => typeof value === 'number' && (value < 0 || value > 100) ? 'Must be from 0 to 100' : undefined
</script>Opening it
The form is the modal: Modal.add(markRaw(PlantForm), {plantId, onSaved}). saved passes the saved plant back to whoever opened it — here it opens the new plant on the page — and after a save in an overlay the form emits close:modal. Load it with defineAsyncComponent where it is only opened, so its code is fetched on first open.
The steps belong to the form, which also renders them as tabs and on a page, so the form keeps them in its own WTabs rather than in a modal's. With stepper-controls the stepper hands its title, progress and buttons to the frame only while it is one; as tabs and on a page it has none.
The page
On a page, the form is all there is: <PlantForm :plant-id="id" async />. Everything shown on the page and in the modal comes from the same item query, so a save in one updates the other through the cache. A field changed but not yet saved keeps its value when another one is saved.
Why it is built this way
- One set of fields. A field, its validation and its control are written once. The modes differ only in layout and in when the model is sent, and both come from props on
WTabsandWUniform. - The frame places the form's parts. The form renders its title and buttons once, in
WModalWrapper, and does not know where it is shown. A modal, a dropdown, a bottom sheet and a page each put them where their layout needs, with their own padding. - The cache is the shared state. The form loads the plant with the item query, and the model's actions write to it. Every open view of the plant stays current without passing data around.
- The form edits its own shape.
initDataturns the API's plant into what the controls need, and the payload is shaped back the way the API takes it.
Variations
- Pick a template first: a first step with cards for common setups calls
scope.initModel(preset)and thennext(), so the next steps start from the preset. - Save in the middle of the wizard:
requireSaveon a step submits the form before moving past it, e.g. when a later step needs the saved id. After that save, the form updates the item it created: build the id fromplantIdor the saved model's id, and passfull-payloadonly while neither exists. - Side tabs:
sideonWTabsputs the tab buttons in a column, for a form with many tabs in a wide modal. - Read-only users: pass
readonlyto the form, or provide it for the whole page withuseProvideReadonly. - From the list: a row menu item that opens
PlantFormwith the row's id edits the plant from the list, and the list's row updates from the cache.