From 6734441855059d01bd087b323ee87ce03631d9ba Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 10 Nov 2026 11:00:56 +0200 Subject: [PATCH] fix(licensing): simplify S08 and reveal linked stage without losing context --- resources/js/lib/investor-path-stage.ts | 15 ++ resources/js/pages/investor-path/show.tsx | 63 ++++-- tests/frontend/licensing-stage.test.mjs | 255 ++++++++++++++++++++++ 3 files changed, 315 insertions(+), 18 deletions(-) create mode 100644 resources/js/lib/investor-path-stage.ts create mode 100644 tests/frontend/licensing-stage.test.mjs diff --git a/resources/js/lib/investor-path-stage.ts b/resources/js/lib/investor-path-stage.ts new file mode 100644 index 00000000..b6dbbb5e --- /dev/null +++ b/resources/js/lib/investor-path-stage.ts @@ -0,0 +1,15 @@ +/** Reveal an existing stage without navigation, remounting fields or changing context. */ +export function revealPathStage(root: HTMLElement, hash: string, codes: string[]): boolean { + const code = hash.startsWith("#") ? hash.slice(1) : ""; + if (!codes.includes(code)) return false; + const target = root.ownerDocument.getElementById(code); + if (!target || !root.contains(target)) return false; + let current: HTMLElement | null = target; + while (current && current !== root) { + if (current.tagName === "DETAILS") (current as HTMLDetailsElement).open = true; + current = current.parentElement; + } + target.scrollIntoView({ block: "start" }); + target.focus({ preventScroll: true }); + return true; +} diff --git a/resources/js/pages/investor-path/show.tsx b/resources/js/pages/investor-path/show.tsx index 8bac92e1..905ed3be 100644 --- a/resources/js/pages/investor-path/show.tsx +++ b/resources/js/pages/investor-path/show.tsx @@ -16,7 +16,8 @@ import FintechInvestment, { } from '@/components/fintech-investment'; import { createOperationKey } from '@/lib/operation-key'; import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { revealPathStage } from '@/lib/investor-path-stage'; import { pathStates, recoverPathResult, @@ -177,16 +178,26 @@ function Stage({ step, path }: { step: Step; path: Path }) { 'expired', ]; const states = path.canWrite ? ownerStates : reviewStates; + const licensing = step.code === 'S08'; + const Container = licensing ? 'section' : 'details'; + const Heading = licensing ? 'h3' : 'summary'; + const Reference = licensing ? 'div' : 'details'; + const ReferenceHeading = licensing ? 'h4' : 'summary'; + const Record = licensing ? 'section' : 'details'; + const RecordHeading = licensing ? 'h4' : 'summary'; return ( -
- + {step.title} · {pathStates[step.state]} - -

{pathStates[step.state]}

+ + {!licensing &&

{pathStates[step.state]}

} {step.context_review_required && (
+ ); } export default function InvestmentPath(path: Path) { + const root = useRef(null); + const stageCodes = path.steps.map((step) => step.code).join(','); + useEffect(() => { + const reveal = () => { + if (root.current) { + revealPathStage(root.current, window.location.hash, stageCodes.split(',')); + } + }; + reveal(); + window.addEventListener('hashchange', reveal); + return () => window.removeEventListener('hashchange', reveal); + }, [path.case.id, path.context_opportunity, stageCodes]); const { auth, flash } = usePage<{ auth: { user: { id: number } }; flash: { success?: string }; @@ -471,7 +498,7 @@ export default function InvestmentPath(path: Path) { ); } return ( -
+
Os meus investimentos diff --git a/tests/frontend/licensing-stage.test.mjs b/tests/frontend/licensing-stage.test.mjs new file mode 100644 index 00000000..7c2c897b --- /dev/null +++ b/tests/frontend/licensing-stage.test.mjs @@ -0,0 +1,255 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { compileFunction } from "node:vm"; +import { createRequire } from "node:module"; +import ts from "typescript"; +import React from "react"; +import { revealPathStage } from "../../resources/js/lib/investor-path-stage.ts"; +const require = createRequire(import.meta.url); +const flatten = (node) => + Array.isArray(node) + ? node.flatMap(flatten) + : node && typeof node === "object" + ? [node, ...flatten(node.props?.children)] + : []; +const text = (node) => + Array.isArray(node) + ? node.map(text).join("") + : node && typeof node === "object" + ? text(node.props?.children) + : String(node ?? ""); +function harness(overrides = {}, react = React) { + const requests = []; + let form; + const module = { exports: {} }; + const source = fs.readFileSync( + new URL("../../resources/js/pages/investor-path/show.tsx", import.meta.url), + "utf8", + ); + const code = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, + }).outputText; + compileFunction(code + "\nmodule.exports.Stage = Stage;", ["require", "module", "exports"])( + (name) => + name === "react" + ? react + : name === "@inertiajs/react" + ? { + usePage: () => ({ props: { auth: { user: { id: 1 } }, flash: {} } }), + useForm(initial) { + form ??= { + data: initial, + errors: {}, + processing: false, + setData(key, value) { + this.data[key] = value; + }, + transform(fn) { + this.convert = fn; + }, + post(url, options) { + requests.push({ url, options, data: this.convert(this.data) }); + }, + }; + return form; + }, + } + : name === "@/lib/investor-path" + ? { pathStates: { preparing: "Em preparação" } } + : name === "@/lib/operation-key" ? { createOperationKey: () => 'qa-key' } + : name === "@/lib/investor-path-stage" ? { revealPathStage } + : name === "@/components/proactive-investment" ? { proactiveView: () => null } + : name.startsWith("@/") + ? {} + : require(name), + module, + module.exports, + ); + const path = { + case: { id: "dossier-qa", version: 7 }, + context_opportunity: "opportunity:42", + investment_context: { revision: 3 }, + canWrite: true, + canReview: false, + steps: [], + next: { code: "S02" }, + documents: [{ document_id: "doc-qa", file_name: "Prova QA", malware_scan_status: "clean" }], + ...overrides, + }; + const step = { + code: "S08", + title: "Licenciamento prévio", + state: "preparing", + preparation_allowed: true, + action_gaps: {}, + detail: {}, + source_paragraph: 8, + }; + return { + render: (changes = {}) => module.exports.Stage({ step: { ...step, ...changes }, path }), + renderPage: () => module.exports.default(path), + requests, + form: () => form, + path, + }; +} +test("S08 has one current state, a visible primary save action and no nested disclosure", () => { + const h = harness(); + const tree = h.render(); + const nodes = flatten(tree); + assert.equal(tree.type, "section"); + assert.equal(nodes.filter((n) => n.type === "details").length, 0); + assert.equal( + nodes.filter((n) => ["h3", "p"].includes(n.type) && text(n).includes("Em preparação")) + .length, + 1, + ); + assert.equal(nodes.find((n) => n.type === "button").props.disabled, false); + assert.match(nodes.find((n) => n.type === "button").props.className, /bg-emerald/); + assert.equal(nodes.filter((n) => n.type === "form").length, 1); + assert.equal(tree.props.tabIndex, -1); +}); +test("editing and saving retain context, current version, fields and existing endpoint", () => { + const h = harness({ canReview: true }); + let tree = h.render(); + const form = h.form(); + Object.assign(form.data, { + reason: "Fundamento deliberado", + reference: "REF-QA", + authority: "Entidade QA", + document_id: "doc-qa", + decision_date: "2026-11-10", + context_reviewed: true, + }); + h.path.case.version = 8; + tree = h.render(); + assert.equal(form.data.reason, "Fundamento deliberado"); + assert.equal(flatten(tree).filter((n) => n.type === "input").length, 3); + flatten(tree) + .find((n) => n.type === "form") + .props.onSubmit({ preventDefault() {} }); + assert.deepEqual(h.requests, [ + { + url: "/investimentos/dossier-qa/etapas/S08", + options: { preserveScroll: true }, + data: { ...form.data, version: 8 }, + }, + ]); +}); +test("read-only and inapplicable stages do not acquire write controls; errors and processing remain visible", () => { + const read = harness({ canWrite: false, canReview: false }); + assert.equal(flatten(read.render()).filter((n) => n.type === "form").length, 0); + const h = harness(); + h.render(); + assert.equal( + flatten(h.render({ preparation_allowed: false })).filter((n) => n.type === "form").length, + 0, + ); + h.form().processing = true; + h.form().errors = { reason: "Conflito de versão" }; + const nodes = flatten(h.render()); + assert.equal(nodes.find((n) => n.type === "button").props.disabled, true); + assert.equal(text(nodes.find((n) => n.props.role === "alert")), "Conflito de versão"); +}); +test("other stages keep their existing disclosure and reviewer fields/action choices", () => { + const h = harness({ canWrite: false, canReview: true }); + const tree = h.render({ code: "S07" }); + assert.equal(tree.type, "details"); + assert.equal(flatten(tree).filter((n) => n.type === "details").length, 3); + assert.equal(h.form().data.action, "review"); + assert.match(text(tree), /Registar acto externo/); + assert.match(text(tree), /Documento de suporte/); +}); +function targetFixture() { + const calls = []; + const root = { + contains: (node) => node === target, + ownerDocument: { getElementById: (id) => (id === "S08" ? target : null) }, + }; + const phase = { tagName: "DETAILS", open: false, parentElement: root }; + const target = { + tagName: "SECTION", + parentElement: phase, + scrollIntoView: (options) => calls.push(["scroll", options]), + focus: (options) => calls.push(["focus", options]), + }; + return { root, phase, target, calls }; +} +test("deep-link opens the containing phase and focuses S08 without navigation or changing fields", () => { + const f = targetFixture(); + assert.equal(revealPathStage(f.root, "#S08", ["S02", "S08"]), true); + assert.equal(f.phase.open, true); + assert.deepEqual(f.calls, [ + ["scroll", { block: "start" }], + ["focus", { preventScroll: true }], + ]); + f.phase.open = false; + assert.equal(revealPathStage(f.root, "#S08", ["S08"]), true); + assert.equal(f.phase.open, true); +}); +test("unknown, absent or out-of-scope hash targets have no effect", () => { + const f = targetFixture(); + for (const hash of ["", "#unknown", "#S08%22", "#S07"]) + assert.equal(revealPathStage(f.root, hash, ["S08"]), false); + f.root.contains = () => false; + assert.equal(revealPathStage(f.root, "#S08", ["S08"]), false); + assert.equal(f.phase.open, false); + assert.deepEqual(f.calls, []); +}); +test("page reveals the initial hash, listens for later hash changes and cleans up the listener", () => { + const f = targetFixture(), + effects = [], + listeners = new Map(); + const previousWindow = globalThis.window; + globalThis.window = { + location: { hash: "#S08" }, + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type, fn) => { + assert.equal(listeners.get(type), fn); + listeners.delete(type); + }, + }; + try { + const h = harness( + { + investment_context: null, + facts: {}, + fact_reasons: {}, + phases: {}, + fintech: null, + sector_contexts: [], + requests: [], + tasks: [], + results: [], + tools: {}, + content_sources: [], + services: [], + steps: [{ code: "S08", preparation_allowed: true }], + }, + { + ...React, + useRef: () => ({ current: f.root }), + useEffect: (effect) => effects.push(effect), + useState: (initial) => [ + typeof initial === "function" ? initial() : initial, + () => {}, + ], + }, + ); + h.renderPage(); + assert.equal(f.phase.open, false); + const cleanup = effects[0](); + assert.equal(f.phase.open, true); + f.phase.open = false; + listeners.get("hashchange")(); + assert.equal(f.phase.open, true); + cleanup(); + assert.equal(listeners.size, 0); + } finally { + globalThis.window = previousWindow; + } +});