From b7e35496ef7cee846c9a47e56ac70430d92384ec Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 23 May 2026 12:45:29 +0800 Subject: [PATCH] feat: add static form submit role --- package.json | 2 +- spec/RouteEngine.form.test.js | 62 +++++--------- ...EffectsHandler.routeGraphicsEvents.test.js | 17 +++- src/createEffectsHandler.js | 53 +++++++++--- src/schemas/presentationActions.yaml | 4 +- src/schemas/systemActions.yaml | 6 -- src/stores/constructRenderState.js | 82 ++++++++++--------- src/stores/system.store.js | 2 - 8 files changed, 124 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index c2e573c..d0056a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.20.2", + "version": "1.20.3", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/RouteEngine.form.test.js b/spec/RouteEngine.form.test.js index bb1556b..3046c6a 100644 --- a/spec/RouteEngine.form.test.js +++ b/spec/RouteEngine.form.test.js @@ -31,28 +31,11 @@ const createProjectData = () => ({ { id: "submit-button", type: "rect", + formRole: "submit", x: 100, y: 230, width: 120, height: 48, - click: { - payload: { - actions: "${form.submitActions}", - }, - }, - }, - { - id: "cancel-button", - type: "rect", - x: 240, - y: 230, - width: 120, - height: 48, - click: { - payload: { - actions: "${form.cancelActions}", - }, - }, }, ], }, @@ -95,6 +78,7 @@ const createProjectData = () => ({ id: "line1", actions: { form: { + id: "profile-contact-form", resourceId: "profileForm", fields: { name: { @@ -113,9 +97,6 @@ const createProjectData = () => ({ submitActions: { nextLine: {}, }, - cancelActions: { - rollbackByOffset: {}, - }, }, }, }, @@ -171,13 +152,11 @@ const findElement = (node, id) => { }; describe("RouteEngine forms", () => { - it("renders form inputs with field drafts and prepared submit/cancel actions", () => { + it("renders form inputs with field drafts and formRole submit actions", () => { const engine = createEngine(); const renderState = engine.selectRenderState(); const nameInput = findElement(renderState.elements, "name-input"); const submitButton = findElement(renderState.elements, "submit-button"); - const cancelButton = findElement(renderState.elements, "cancel-button"); - expect(nameInput).toMatchObject({ type: "input", value: "", @@ -189,7 +168,6 @@ describe("RouteEngine forms", () => { updateFormField: { field: "name", value: "_event.value", - _interactionSource: "form", }, }, }, @@ -199,37 +177,36 @@ describe("RouteEngine forms", () => { _interactionSource: "form", actions: { submitForm: { - formId: "profileForm", + formKey: "section1:line1:profile-contact-form", }, }, }, }, }); - expect(nameInput.change.payload._formKey).toBe( - "section1:line1:profileForm", + expect(nameInput.change.payload).not.toHaveProperty("_formId"); + expect(nameInput.change.payload).not.toHaveProperty("_formKey"); + expect(nameInput.change.payload.actions.updateFormField.formKey).toBe( + "section1:line1:profile-contact-form", + ); + expect(nameInput.change.payload.actions.updateFormField).not.toHaveProperty( + "formId", ); + expect(submitButton.click.payload).not.toHaveProperty("_formId"); + expect(submitButton.click.payload).not.toHaveProperty("_formKey"); expect(submitButton.click.payload).toMatchObject({ _interactionSource: "form", - _formKey: "section1:line1:profileForm", actions: { submitForm: { - formId: "profileForm", - formKey: "section1:line1:profileForm", + formKey: "section1:line1:profile-contact-form", actions: { nextLine: {}, }, }, }, }); - expect(cancelButton.click.payload.actions).toEqual({ - cancelForm: { - formId: "profileForm", - formKey: "section1:line1:profileForm", - actions: { - rollbackByOffset: {}, - }, - }, - }); + expect(submitButton.click.payload.actions.submitForm).not.toHaveProperty( + "formId", + ); }); it("keeps edits transient until a valid multi-field submit commits variables and runs actions", () => { @@ -260,8 +237,9 @@ describe("RouteEngine forms", () => { expect(nameInput.value).toBe(" Ada "); expect(emailInput.value).toBe(""); expect( - engine.selectSystemState().global.formDrafts["section1:line1:profileForm"] - .errors.email, + engine.selectSystemState().global.formDrafts[ + "section1:line1:profile-contact-form" + ].errors.email, ).toBe("required"); engine.handleActions(emailInput.change.payload.actions, { diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index e068082..b218b3f 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -344,7 +344,6 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { await eventHandler("change", { _interactionSource: "form", - _formKey: "section1:line1:profileForm", actions: { updateFormField: { formKey: "section1:line1:profileForm", @@ -374,6 +373,22 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { interactionSource: "form", }, ); + + engine.handleActions.mockClear(); + + await eventHandler("click", { + _interactionSource: "form", + actions: { + submitForm: { + formKey: "section1:line9:profileForm", + actions: { + nextLine: {}, + }, + }, + }, + }); + + expect(engine.handleActions).not.toHaveBeenCalled(); }); it("coalesces replaceable effects by name and keeps the last payload", () => { diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index 78cf348..966e8ec 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -382,6 +382,12 @@ const createEffectsHandler = ({ return true; }; + const formInteractionActionTypes = new Set([ + "updateFormField", + "submitForm", + "cancelForm", + ]); + const getActiveInteraction = (engine) => { if (typeof engine?.selectActiveInteraction === "function") { return engine.selectActiveInteraction(); @@ -399,6 +405,12 @@ const createEffectsHandler = ({ return null; }; + const getFormInteractionKey = (value) => value?._formKey ?? value?.formKey; + + const hasMatchingFormKey = (value, activeInteraction) => { + return getFormInteractionKey(value) === activeInteraction?.formKey; + }; + const matchesInteraction = (value, activeInteraction) => { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; @@ -408,31 +420,48 @@ const createEffectsHandler = ({ return false; } - if ( - activeInteraction.source === "form" && - value._formKey && - value._formKey !== activeInteraction.formKey - ) { + if (activeInteraction.source === "form") { + return hasMatchingFormKey(value, activeInteraction); + } + + return true; + }; + + const matchesFormAction = (value, activeInteraction) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } - if ( - activeInteraction.source === "form" && - value.formKey && - value.formKey !== activeInteraction.formKey - ) { + if (activeInteraction?.source !== "form") { return false; } - return true; + return hasMatchingFormKey(value, activeInteraction); }; const isInteractionPayload = (payload = {}, activeInteraction) => { + const actions = payload?.actions; + + if (activeInteraction?.source === "form") { + if (matchesInteraction(payload, activeInteraction)) { + return true; + } + + if (!actions || typeof actions !== "object" || Array.isArray(actions)) { + return false; + } + + return Object.entries(actions).some( + ([actionType, actionPayload]) => + formInteractionActionTypes.has(actionType) && + matchesFormAction(actionPayload, activeInteraction), + ); + } + if (matchesInteraction(payload, activeInteraction)) { return true; } - const actions = payload?.actions; if (!actions || typeof actions !== "object" || Array.isArray(actions)) { return false; } diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index 4733ea7..ac69855 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -372,10 +372,10 @@ properties: properties: id: type: string - description: Optional stable form ID. Defaults to resourceId. + description: Stable generated form ID. Defaults to resourceId for compatibility. resourceId: type: string - description: ID of the form layout UI element + description: ID of the form layout resource to render fields: type: object description: Form fields keyed by layout field ID diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index 939c35c..e04b72a 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -434,8 +434,6 @@ properties: type: object description: Update a transient active form field draft properties: - formId: - type: string formKey: type: string field: @@ -449,8 +447,6 @@ properties: type: object description: Submit the active form and run actions after a valid commit properties: - formId: - type: string formKey: type: string actions: @@ -462,8 +458,6 @@ properties: type: object description: Cancel the active form and run follow-up actions properties: - formId: - type: string formKey: type: string actions: diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 87a1899..1406b7c 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1540,6 +1540,8 @@ const FORM_EVENT_KEYS = new Set([ "compositionEnd", ]); +const FORM_ROLE_SUBMIT = "submit"; + const mergeEventActions = (eventConfig, actions) => { const payload = eventConfig?.payload && @@ -1564,9 +1566,9 @@ const mergeEventActions = (eventConfig, actions) => { }; }; -const enrichFormInputs = (node, form) => { +const enrichFormElements = (node, form) => { if (Array.isArray(node)) { - return node.map((item) => enrichFormInputs(item, form)); + return node.map((item) => enrichFormElements(item, form)); } if (!node || typeof node !== "object") { @@ -1575,45 +1577,51 @@ const enrichFormInputs = (node, form) => { const enrichedNode = {}; for (const [key, value] of Object.entries(node)) { - enrichedNode[key] = enrichFormInputs(value, form); + enrichedNode[key] = enrichFormElements(value, form); } - if (enrichedNode.type !== "input" || typeof enrichedNode.field !== "string") { - return enrichedNode; - } + let formNode = enrichedNode; + + if (formNode.type === "input" && typeof formNode.field === "string") { + const field = form?.fields?.[formNode.field]; - const field = form?.fields?.[enrichedNode.field]; - if (!field) { - return enrichedNode; + if (field) { + const updateFormField = { + updateFormField: { + formKey: form.key, + field: field.id, + value: "_event.value", + }, + }; + + formNode = { + ...formNode, + value: field.value, + placeholder: + formNode.placeholder === undefined + ? field.placeholder + : formNode.placeholder, + multiline: + formNode.multiline === undefined + ? field.multiline + : formNode.multiline, + ...(formNode.maxLength === undefined && field.maxLength !== undefined + ? { maxLength: field.maxLength } + : {}), + change: mergeEventActions(formNode.change, updateFormField), + submit: mergeEventActions(formNode.submit, form.submitActions), + }; + } } - const updateFormField = { - updateFormField: { - formId: form.id, - formKey: form.key, - field: field.id, - value: "_event.value", - _interactionSource: "form", - }, - }; + if (formNode.formRole === FORM_ROLE_SUBMIT) { + return { + ...formNode, + click: mergeEventActions(formNode.click, form.submitActions), + }; + } - return { - ...enrichedNode, - value: field.value, - placeholder: - enrichedNode.placeholder === undefined - ? field.placeholder - : enrichedNode.placeholder, - multiline: - enrichedNode.multiline === undefined - ? field.multiline - : enrichedNode.multiline, - ...(enrichedNode.maxLength === undefined && field.maxLength !== undefined - ? { maxLength: field.maxLength } - : {}), - change: mergeEventActions(enrichedNode.change, updateFormField), - submit: mergeEventActions(enrichedNode.submit, form.submitActions), - }; + return formNode; }; const tagFormInteractionSource = (node, form) => { @@ -1641,8 +1649,6 @@ const tagFormInteractionSource = (node, form) => { payload: { ...payload, _interactionSource: "form", - _formId: form.id, - _formKey: form.key, }, }; } @@ -2987,7 +2993,7 @@ export const addForm = ( }); const formElements = tagFormInteractionSource( resolveLayoutResourceIds( - settleTextRevealIfCompleted(enrichFormInputs(processedForm, form), { + settleTextRevealIfCompleted(enrichFormElements(processedForm, form), { isLineCompleted, skipMode, skipTransitionsAndAnimations, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index c56ddad..4c2bbab 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -885,14 +885,12 @@ const buildFormTemplateData = ({ state, form, pointer }) => { values, submitActions: { submitForm: { - formId, formKey, actions: cloneStateValue(form.submitActions ?? {}), }, }, cancelActions: { cancelForm: { - formId, formKey, actions: cloneStateValue(form.cancelActions ?? {}), },