diff --git a/package.json b/package.json index d0056a4..e582780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.20.3", + "version": "1.20.4", "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 3046c6a..5977266 100644 --- a/spec/RouteEngine.form.test.js +++ b/spec/RouteEngine.form.test.js @@ -30,12 +30,29 @@ const createProjectData = () => ({ }, { id: "submit-button", - type: "rect", + type: "container", formRole: "submit", x: 100, y: 230, width: 120, height: 48, + children: [ + { + id: "submit-label", + type: "rect", + x: 0, + y: 0, + width: 120, + height: 48, + click: { + payload: { + actions: { + nextLine: {}, + }, + }, + }, + }, + ], }, ], }, @@ -116,7 +133,7 @@ const createProjectData = () => ({ }, }); -const createEngine = () => { +const createEngine = ({ markLineCompleted = true } = {}) => { const engine = createRouteEngine({ handlePendingEffects: () => {}, }); @@ -126,7 +143,10 @@ const createEngine = () => { projectData: createProjectData(), }, }); - engine.handleAction("markLineCompleted", {}); + + if (markLineCompleted) { + engine.handleAction("markLineCompleted", {}); + } return engine; }; @@ -207,6 +227,81 @@ describe("RouteEngine forms", () => { expect(submitButton.click.payload.actions.submitForm).not.toHaveProperty( "formId", ); + expect(Object.keys(submitButton.click.payload.actions)).toEqual([ + "submitForm", + ]); + }); + + it("submits when clicking a child inside a submit-role container", () => { + const engine = createEngine({ markLineCompleted: false }); + let renderState = engine.selectRenderState(); + const nameInput = findElement(renderState.elements, "name-input"); + const emailInput = findElement(renderState.elements, "email-input"); + let submitLabel = findElement(renderState.elements, "submit-label"); + + expect(submitLabel.click.payload).toMatchObject({ + _interactionSource: "form", + actions: { + submitForm: { + formKey: "section1:line1:profile-contact-form", + actions: { + nextLine: {}, + }, + }, + }, + }); + + expect(Object.keys(submitLabel.click.payload.actions)).toEqual([ + "submitForm", + ]); + + engine.handleActions(nameInput.change.payload.actions, { + _event: { + value: "Ada", + }, + }); + engine.handleActions(emailInput.change.payload.actions, { + _event: { + value: "ada@example.com", + }, + }); + + renderState = engine.selectRenderState(); + submitLabel = findElement(renderState.elements, "submit-label"); + engine.handleActions(submitLabel.click.payload.actions); + + expect(engine.selectSystemState().contexts[0].pointers.read.lineId).toBe( + "line2", + ); + }); + + it("valid form submit advances even before the form line is marked completed", () => { + const engine = createEngine({ markLineCompleted: false }); + let renderState = engine.selectRenderState(); + const nameInput = findElement(renderState.elements, "name-input"); + const emailInput = findElement(renderState.elements, "email-input"); + let submitButton = findElement(renderState.elements, "submit-button"); + + expect(engine.selectSystemState().global.isLineCompleted).toBe(false); + + engine.handleActions(nameInput.change.payload.actions, { + _event: { + value: "Ada", + }, + }); + engine.handleActions(emailInput.change.payload.actions, { + _event: { + value: "ada@example.com", + }, + }); + + renderState = engine.selectRenderState(); + submitButton = findElement(renderState.elements, "submit-button"); + engine.handleActions(submitButton.click.payload.actions); + + expect(engine.selectSystemState().contexts[0].pointers.read.lineId).toBe( + "line2", + ); }); it("keeps edits transient until a valid multi-field submit commits variables and runs actions", () => { diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 1406b7c..96811ac 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1542,7 +1542,7 @@ const FORM_EVENT_KEYS = new Set([ const FORM_ROLE_SUBMIT = "submit"; -const mergeEventActions = (eventConfig, actions) => { +const mergeEventActions = (eventConfig, actions, options = {}) => { const payload = eventConfig?.payload && typeof eventConfig.payload === "object" && @@ -1553,31 +1553,44 @@ const mergeEventActions = (eventConfig, actions) => { payload.actions && typeof payload.actions === "object" ? payload.actions : {}; + const nextActions = + options.replaceActions === true + ? actions + : { + ...existingActions, + ...actions, + }; return { ...(eventConfig || {}), payload: { ...payload, - actions: { - ...existingActions, - ...actions, - }, + actions: nextActions, }, }; }; -const enrichFormElements = (node, form) => { +const enrichFormElements = (node, form, options = {}) => { if (Array.isArray(node)) { - return node.map((item) => enrichFormElements(item, form)); + return node.map((item) => enrichFormElements(item, form, options)); } if (!node || typeof node !== "object") { return node; } + const isElementNode = typeof node.type === "string"; + const isSubmitRole = isElementNode && node.formRole === FORM_ROLE_SUBMIT; + const inheritsSubmitRole = isElementNode && options.submitRoleAncestor; + const childOptions = + isSubmitRole || inheritsSubmitRole ? { submitRoleAncestor: true } : {}; const enrichedNode = {}; for (const [key, value] of Object.entries(node)) { - enrichedNode[key] = enrichFormElements(value, form); + enrichedNode[key] = enrichFormElements( + value, + form, + key === "children" ? childOptions : {}, + ); } let formNode = enrichedNode; @@ -1614,10 +1627,12 @@ const enrichFormElements = (node, form) => { } } - if (formNode.formRole === FORM_ROLE_SUBMIT) { + if (formNode.formRole === FORM_ROLE_SUBMIT || inheritsSubmitRole) { return { ...formNode, - click: mergeEventActions(formNode.click, form.submitActions), + click: mergeEventActions(formNode.click, form.submitActions, { + replaceActions: true, + }), }; } diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 4c2bbab..746646b 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -3420,6 +3420,22 @@ const createSubmittedFormContext = (activeForm, values) => ({ fieldList: cloneStateValue(activeForm.fieldList), }); +const completeCurrentLineForSubmittedForm = (state) => { + if (state.global.isLineCompleted) { + return; + } + + state.global.isLineCompleted = true; + delete state.global.pendingScreenTransition; + + const pointer = selectCurrentPointer({ state })?.pointer; + const sectionId = pointer?.sectionId; + const lineId = pointer?.lineId; + if (sectionId && lineId) { + recordViewedLine(state, { sectionId, lineId }); + } +}; + export const updateFormField = ({ state }, payload = {}) => { const activeForm = getActiveFormForPayload(state, payload); const fieldId = payload.field; @@ -3546,6 +3562,7 @@ export const submitForm = ({ state }, payload = {}) => { queueScopedDataPersistence(state, globalUpdates); const formContext = createSubmittedFormContext(activeForm, storedValues); + completeCurrentLineForSubmittedForm(state); delete state.global.formDrafts[activeForm.key]; state.global.pendingEffects.push({ name: "render", diff --git a/vt/specs/form/basic.yaml b/vt/specs/form/basic.yaml index 781d41e..45411e0 100644 --- a/vt/specs/form/basic.yaml +++ b/vt/specs/form/basic.yaml @@ -5,7 +5,7 @@ specs: - background nextLine clicks are blocked while the form is visible - empty submit keeps the form visible and shows required errors - filled inputs stay visible before submit - - filling both inputs and clicking submit commits variables and advances + - filling both inputs and clicking submit child commits variables and advances skipInitialScreenshot: true viewport: id: capture @@ -138,21 +138,30 @@ resources: content: "Code is required" textStyleId: errorText - id: submit-button - type: rect + type: container + formRole: submit x: 650 y: 420 width: 190 height: 58 - colorId: submitColor - click: - payload: - actions: ${form.submitActions} - - id: submit-label - type: text - x: 706 - y: 436 - content: "Submit" - textStyleId: buttonText + children: + - id: submit-hit + type: rect + x: 0 + y: 0 + width: 190 + height: 58 + colorId: submitColor + click: + payload: + actions: + nextLine: {} + - id: submit-label + type: text + x: 56 + y: 16 + content: "Submit" + textStyleId: buttonText resultLayout: elements: - id: result-title