fix(licensing): simplify S08 and reveal linked stage without losing context
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<details
|
||||
<Container
|
||||
id={step.code}
|
||||
open={path.next?.code === step.code}
|
||||
className="space-y-3 rounded-xl border p-4"
|
||||
tabIndex={-1}
|
||||
open={licensing ? undefined : path.next?.code === step.code}
|
||||
className={`space-y-3 rounded-xl border p-4 ${licensing ? 'md:col-span-2' : ''}`}
|
||||
>
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
<Heading
|
||||
className={licensing ? 'text-lg font-semibold' : 'cursor-pointer font-semibold'}
|
||||
>
|
||||
{step.title} · {pathStates[step.state]}
|
||||
</summary>
|
||||
<p>{pathStates[step.state]}</p>
|
||||
</Heading>
|
||||
{!licensing && <p>{pathStates[step.state]}</p>}
|
||||
{step.context_review_required && (
|
||||
<label className="block rounded border border-amber-600 bg-amber-50 p-3">
|
||||
<input
|
||||
@@ -258,10 +269,10 @@ function Stage({ step, path }: { step: Step; path: Path }) {
|
||||
. Pode continuar a preparar.
|
||||
</p>
|
||||
))}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer underline">
|
||||
<Reference className="text-xs">
|
||||
<ReferenceHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
|
||||
Fonte e referência desta etapa
|
||||
</summary>
|
||||
</ReferenceHeading>
|
||||
<p>
|
||||
Referência interna {step.code}. Documento original,
|
||||
parágrafo {step.source_paragraph}.{' '}
|
||||
@@ -271,12 +282,14 @@ function Stage({ step, path }: { step: Step; path: Path }) {
|
||||
? 'Acto externo registado a partir de documento.'
|
||||
: 'Preparação declarada pelo promotor.'}
|
||||
</p>
|
||||
</details>
|
||||
</Reference>
|
||||
{step.preparation_allowed && (path.canWrite || path.canReview) && (
|
||||
<details>
|
||||
<summary className="cursor-pointer underline">
|
||||
<Record
|
||||
aria-label={licensing ? 'Registar licenciamento' : undefined}
|
||||
>
|
||||
<RecordHeading className={licensing ? 'font-semibold' : 'cursor-pointer underline'}>
|
||||
Registar avanço ou pendência
|
||||
</summary>
|
||||
</RecordHeading>
|
||||
<form
|
||||
className="mt-3 grid gap-3"
|
||||
onSubmit={(event) => {
|
||||
@@ -424,18 +437,32 @@ function Stage({ step, path }: { step: Step; path: Path }) {
|
||||
))}
|
||||
<button
|
||||
disabled={form.processing}
|
||||
className="rounded border p-2"
|
||||
className={licensing
|
||||
? 'rounded bg-emerald-800 px-4 py-3 font-semibold text-white disabled:opacity-50'
|
||||
: 'rounded border p-2'}
|
||||
>
|
||||
Guardar registo
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</Record>
|
||||
)}
|
||||
</details>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvestmentPath(path: Path) {
|
||||
const root = useRef<HTMLElement>(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 (
|
||||
<main className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
|
||||
<main ref={root} className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
|
||||
<Head title={path.case.title} />
|
||||
<Link className="underline" href="/investimentos">
|
||||
Os meus investimentos
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user