mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
import { cloneAny } from '@formkit/utils'
|
|
|
|
import type { FormFieldValue } from '../types.ts'
|
|
import type { FormKitNode } from '@formkit/core'
|
|
|
|
// FormKit determines dirty state by comparing _init (initial values snapshot) against
|
|
// _value (current values). When fields are dynamically added or removed — e.g. via
|
|
// core workflow toggling field visibility with "show" — FormKit updates _value but
|
|
// never syncs _init. This mismatch causes the form to appear dirty even though no
|
|
// user edit occurred.
|
|
//
|
|
// The _init object exists at every level of the node tree (form, groups) and contains
|
|
// nested snapshots. When a field inside a group changes, both the group's _init
|
|
// AND the form's _init (which nests the group's snapshot) must be updated.
|
|
//
|
|
// This plugin fixes the problem by:
|
|
// - On child removal: deleting the field's key from _init at the direct parent
|
|
// and all ancestor levels, so _init stays in sync with _value.
|
|
// - On child re-add: restoring the previously saved _init value, so that toggling
|
|
// a field off and back on doesn't lose the baseline for dirty comparison.
|
|
// - On new child: syncing the child's initial value into ancestor _init after it
|
|
// settles, covering fields shown for the first time after the form settled.
|
|
// - On reset: clearing saved values, since reset() overwrites _init entirely.
|
|
const initializeFieldInitialValuesCleanupPlugin = (node: FormKitNode) => {
|
|
if (node.type !== 'group') return
|
|
|
|
// Stores _init values of removed children so they can be restored when the
|
|
// same field is re-added (e.g. core workflow toggles a field off then back on).
|
|
// Cleared on reset because reset() sets a fresh _init, making saved values stale.
|
|
const savedInitValues = new Map<string, FormFieldValue>()
|
|
|
|
// Returns true once the form has completed its initial settle (i.e., after
|
|
// formKitInitialNodesSettled is set in Form.vue). Used to switch from the
|
|
// settled.then() capture strategy to synchronous capture for newly shown fields.
|
|
const isFormSettled = () => {
|
|
let root: FormKitNode = node
|
|
while (root.parent) root = root.parent
|
|
return !!root.props._formSettled
|
|
}
|
|
|
|
// Re-evaluates the dirty state for this node and all ancestors.
|
|
// Required because FormKit's removeChild() triggers the dirty check (touch)
|
|
// BEFORE emitting childRemoved — so by the time our plugin cleans _init,
|
|
// the dirty state was already set based on the stale _init.
|
|
const reevaluateDirtyState = () => {
|
|
node.context?.handlers?.touch()
|
|
|
|
let ancestor = node.parent
|
|
while (ancestor) {
|
|
ancestor.context?.handlers?.touch()
|
|
ancestor = ancestor.parent
|
|
}
|
|
}
|
|
|
|
// Navigates into a nested object following a path of keys.
|
|
// Returns the nested object at the end of the path, or undefined if any
|
|
// segment is missing or not an object.
|
|
const resolveNestedInit = (root: Record<string, unknown>, path: string[]) => {
|
|
return path.reduce<Record<string, unknown> | undefined>((target, segment) => {
|
|
if (target?.[segment] && typeof target[segment] === 'object') {
|
|
return target[segment] as Record<string, unknown>
|
|
}
|
|
return undefined
|
|
}, root)
|
|
}
|
|
|
|
// Applies an operation to the nested _init object at every ancestor level.
|
|
// For a tree like form → group "ticket" → field "priority", removing "priority"
|
|
// must clean both group._init.priority and form._init.ticket.priority.
|
|
const walkAncestors = (operation: (target: Record<string, unknown>) => void) => {
|
|
let ancestor = node.parent
|
|
const path: string[] = [node.name]
|
|
|
|
while (ancestor) {
|
|
if (ancestor.props._init && typeof ancestor.props._init === 'object') {
|
|
const target = resolveNestedInit(ancestor.props._init, path)
|
|
if (target) operation(target)
|
|
}
|
|
|
|
path.unshift(ancestor.name)
|
|
ancestor = ancestor.parent
|
|
}
|
|
}
|
|
|
|
node.on('childRemoved', ({ payload: child }) => {
|
|
if (
|
|
node.props._init &&
|
|
typeof node.props._init === 'object' &&
|
|
child.name in node.props._init
|
|
) {
|
|
savedInitValues.set(child.name, node.props._init[child.name])
|
|
delete node.props._init[child.name]
|
|
}
|
|
|
|
walkAncestors((target) => {
|
|
if (child.name in target) {
|
|
delete target[child.name]
|
|
}
|
|
})
|
|
|
|
reevaluateDirtyState()
|
|
})
|
|
|
|
node.on('child', ({ payload: child }) => {
|
|
// Re-add of a previously removed field: restore the saved _init value
|
|
// immediately at this level and all ancestors.
|
|
if (savedInitValues.has(child.name)) {
|
|
const savedValue = savedInitValues.get(child.name)
|
|
savedInitValues.delete(child.name)
|
|
|
|
if (node.props._init && typeof node.props._init === 'object') {
|
|
node.props._init[child.name] = savedValue
|
|
}
|
|
|
|
walkAncestors((target) => {
|
|
target[child.name] = savedValue
|
|
})
|
|
|
|
reevaluateDirtyState()
|
|
return
|
|
}
|
|
|
|
// Post-initial new field: capture _init synchronously here, before any
|
|
// async form-updater value (applied via node.input() in a nextTick) can run.
|
|
// The settled.then() path intentionally waits for async updates — which is
|
|
// correct for initial setup (changeInitialValue) but wrong post-initial,
|
|
// where form-updater values should make the field dirty relative to _init.
|
|
if (isFormSettled() && node.props._init && typeof node.props._init === 'object') {
|
|
const initialValue = cloneAny(child.value) as FormFieldValue
|
|
node.props._init[child.name] = initialValue
|
|
walkAncestors((target) => {
|
|
target[child.name] = initialValue
|
|
})
|
|
reevaluateDirtyState()
|
|
return
|
|
}
|
|
|
|
// New field shown for the first time (e.g. initially hidden, then revealed
|
|
// by core workflow after the form already settled). FormKit's addChild
|
|
// updates _value but not _init at ancestor levels. Wait for the child to
|
|
// settle so its value is final, then sync into ancestor _init.
|
|
child.settled.then(() => {
|
|
// Guard against the child or parent being destroyed before settle resolves
|
|
// (e.g. rapid field toggling).
|
|
if (!child.context || !node.context) return
|
|
|
|
if (!node.props._init || typeof node.props._init !== 'object') return
|
|
|
|
// Deep-clone here (after settle) so we capture the fully resolved value,
|
|
// including any async updates (e.g. form-updater setting the field's value
|
|
// after showing it). For group nodes, child.value is a live reference that
|
|
// will be mutated when descendants change — cloning locks in this snapshot
|
|
// so _init doesn't silently track _value and prevent dirty detection.
|
|
const initialValue = cloneAny(child.value) as FormFieldValue
|
|
|
|
// Always sync into _init, even if the key already exists. FormKit's
|
|
// reset() sets _init at the root level from resetTo — which can include
|
|
// values for hidden fields that have no child node. When such a field is
|
|
// later shown, its child.value (hydrated from the parent's live _value)
|
|
// may differ from what reset() stored in _init, causing a false dirty
|
|
// state. Overwriting ensures _init reflects the child's actual settled
|
|
// value, regardless of how the key got there.
|
|
node.props._init[child.name] = initialValue
|
|
|
|
// Sync into all ancestor _init objects.
|
|
walkAncestors((target) => {
|
|
target[child.name] = initialValue
|
|
})
|
|
|
|
reevaluateDirtyState()
|
|
})
|
|
})
|
|
|
|
node.on('reset', () => {
|
|
savedInitValues.clear()
|
|
})
|
|
}
|
|
|
|
export default initializeFieldInitialValuesCleanupPlugin
|