refactor(landing): project landing is now a single-file SPA, not server-rendered
The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).
Mechanics:
- landing/template.html: pickerView (existing markup) + projectView
(new: stage cards, browse-all, MDL section, party-list slot).
Mode toggles by adding/removing .hidden on the two containers.
- landing/js/landing.js: detectMode() reads location.pathname;
renderProjectMode() populates stage hrefs from the project segment
and fetches /<project>/archive/?json=1 for the party list. init()
forks based on mode; picker init was extracted to initPicker().
Existing public API + behaviour unchanged for picker mode.
- landing/css/landing.css: appended ~115 lines for the project view
(.stages grid, .stage-card hover, .party-list, MDL formatting).
- cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
the deleted ServeProjectLanding handler.
- internal/handler/projecthandler.go: trimmed to just the
IsProjectRootURL predicate (the dispatcher still needs it for
routing). Template + render code (~220 lines) deleted.
Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.
Tests:
- 3 new tests/landing.spec.js cases: detectMode exposure, project
workspace renders at /<project> with correct stage hrefs + title,
party listing populates from JSON fetch and filters dot-prefixed
entries.
- The dispatcher test for /Project no-slash still asserts 200 +
no-redirect; the served body is now landing.html instead of the
server-rendered template, but both pass the assertion.
LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc5fcf6c73
commit
315d039880
7 changed files with 452 additions and 387 deletions
|
|
@ -342,3 +342,125 @@ body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Project mode ──────────────────────────────────────────────────────── */
|
||||||
|
/* Activated when location.pathname is a single project segment (e.g.
|
||||||
|
/Project-1). Picker UI is hidden; this block surfaces the four
|
||||||
|
lifecycle-stage cards and MDL editing instructions. */
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title__subtle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.25rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stages {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card h3 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectView ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectView ol li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectView code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projectView h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 2.25rem 0 0.5rem;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-list {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.4rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-list li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-list a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-list a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.party-list-none-yet {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -612,9 +612,163 @@
|
||||||
catch (e) { /* private mode / quota */ }
|
catch (e) { /* private mode / quota */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Project mode ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The same landing tool serves at /<project> as the project-workspace
|
||||||
|
// page. Mode is determined from location.pathname:
|
||||||
|
//
|
||||||
|
// / → 'picker' (existing behavior)
|
||||||
|
// /<single-segment> → 'project'
|
||||||
|
// /index.html → 'picker' (file:// + standalone-served root)
|
||||||
|
// anything else → 'picker' (best-effort fallback)
|
||||||
|
//
|
||||||
|
// Project mode shows the four canonical lifecycle-stage cards, a
|
||||||
|
// "browse all files" link, and a Master Deliverables List section
|
||||||
|
// with direct links to any parties currently in archive/. The party
|
||||||
|
// list is fetched from <project>/<archive>/?json=1; failures fall
|
||||||
|
// back to the static "no parties yet" copy.
|
||||||
|
|
||||||
|
function detectMode() {
|
||||||
|
if (typeof location === 'undefined') return 'picker';
|
||||||
|
var path = location.pathname || '/';
|
||||||
|
// Strip any trailing /index.html so the deployment-root case
|
||||||
|
// matches even on file:// or behind some servers.
|
||||||
|
var trimmed = path.replace(/\/index\.html$/, '/');
|
||||||
|
if (trimmed === '' || trimmed === '/') return 'picker';
|
||||||
|
// Single non-slash, non-dot segment → project root.
|
||||||
|
var parts = trimmed.split('/').filter(Boolean);
|
||||||
|
if (parts.length === 1 && parts[0].indexOf('.') === -1) {
|
||||||
|
return 'project';
|
||||||
|
}
|
||||||
|
return 'picker';
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectFromPath() {
|
||||||
|
var parts = (location.pathname || '/').split('/').filter(Boolean);
|
||||||
|
return parts[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the project-workspace view: title, four stage links, MDL
|
||||||
|
// section. Stage hrefs use the no-trailing-slash form so the server
|
||||||
|
// routes them to each canonical default tool (mdedit for working/,
|
||||||
|
// transmittal for staging/, etc.). Browse-all and the archive deep
|
||||||
|
// link use the slash form to land on the directory listing.
|
||||||
|
async function renderProjectMode() {
|
||||||
|
var project = projectFromPath();
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
// Hide picker, show project view.
|
||||||
|
var picker = document.getElementById('pickerView');
|
||||||
|
var projectView = document.getElementById('projectView');
|
||||||
|
if (picker) picker.classList.add('hidden');
|
||||||
|
if (projectView) projectView.classList.remove('hidden');
|
||||||
|
|
||||||
|
document.title = project + ' — ZDDC';
|
||||||
|
var titleEl = document.getElementById('projectName');
|
||||||
|
if (titleEl) titleEl.textContent = project;
|
||||||
|
|
||||||
|
var p = encodeURIComponent(project);
|
||||||
|
var stages = [
|
||||||
|
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||||||
|
{ id: 'stageWorking', href: '/' + p + '/working' },
|
||||||
|
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
||||||
|
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
||||||
|
];
|
||||||
|
for (var i = 0; i < stages.length; i++) {
|
||||||
|
var a = document.getElementById(stages[i].id);
|
||||||
|
if (a) a.setAttribute('href', stages[i].href);
|
||||||
|
}
|
||||||
|
|
||||||
|
var browseAll = document.getElementById('browseAllLink');
|
||||||
|
if (browseAll) {
|
||||||
|
browseAll.setAttribute('href', '/' + p + '/');
|
||||||
|
browseAll.textContent = 'Browse all files →';
|
||||||
|
}
|
||||||
|
var archiveBrowse = document.getElementById('archiveBrowseLink');
|
||||||
|
if (archiveBrowse) {
|
||||||
|
archiveBrowse.setAttribute('href', '/' + p + '/archive/');
|
||||||
|
archiveBrowse.innerHTML = '<code>/' + escapeHtml(project) + '/archive/</code>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch party list. Best-effort — failures render the
|
||||||
|
// no-parties-yet fallback. We try /<project>/archive/ — the
|
||||||
|
// server returns the listing in either lowercase or PascalCase
|
||||||
|
// form; either yields the same JSON shape via case-insensitive
|
||||||
|
// URL canonicalization.
|
||||||
|
var partySection = document.getElementById('partyListSection');
|
||||||
|
if (!partySection) return;
|
||||||
|
|
||||||
|
var parties = await fetchParties(p);
|
||||||
|
if (parties == null) {
|
||||||
|
// Network error or unauthenticated — show neither list nor
|
||||||
|
// explicit "none" message. The page is still usable.
|
||||||
|
partySection.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parties.length === 0) {
|
||||||
|
partySection.innerHTML =
|
||||||
|
'<p class="party-list-none-yet">No party folders yet. The MDL view auto-renders at any '
|
||||||
|
+ '<code>archive/<party>/mdl/</code> URL, even when the folder doesn\'t exist on '
|
||||||
|
+ 'disk — so you can start editing an MDL before any transmittals have been exchanged.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>'
|
||||||
|
+ '<ul class="party-list">';
|
||||||
|
for (var j = 0; j < parties.length; j++) {
|
||||||
|
var name = parties[j].name;
|
||||||
|
var url = parties[j].url; // server-provided absolute URL
|
||||||
|
html += '<li><a href="' + url + 'mdl/">' + escapeHtml(name) + ' MDL →</a></li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
partySection.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an array of {name, url} for each party folder in the
|
||||||
|
// project's archive/, sorted by name. Returns null if the listing
|
||||||
|
// can't be fetched (offline, 4xx, or non-JSON response). Returns
|
||||||
|
// [] if the listing succeeds but archive/ is empty / has no
|
||||||
|
// visible party folders.
|
||||||
|
async function fetchParties(projectURL) {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/' + projectURL + '/archive/', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
cache: 'no-cache',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
var ctype = resp.headers.get('Content-Type') || '';
|
||||||
|
if (!ctype.toLowerCase().includes('json')) return null;
|
||||||
|
var data = await resp.json();
|
||||||
|
if (!Array.isArray(data)) return null;
|
||||||
|
// Server emits directories with trailing "/" on the name.
|
||||||
|
// Filter to dirs only, strip the slash for display.
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
var e = data[i];
|
||||||
|
if (!e.is_dir) continue;
|
||||||
|
var nm = String(e.name || '').replace(/\/$/, '');
|
||||||
|
if (!nm) continue;
|
||||||
|
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
|
||||||
|
out.push({ name: nm, url: e.url || ('/' + projectURL + '/archive/' + encodeURIComponent(nm) + '/') });
|
||||||
|
}
|
||||||
|
out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
|
||||||
|
return out;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bootstrap ────────────────────────────────────────────────────────────
|
// ── Bootstrap ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
if (detectMode() === 'project') {
|
||||||
|
await renderProjectMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await initPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPicker() {
|
||||||
loadGroups();
|
loadGroups();
|
||||||
urlRestore();
|
urlRestore();
|
||||||
|
|
||||||
|
|
@ -669,6 +823,9 @@
|
||||||
saveGroup: saveGroup,
|
saveGroup: saveGroup,
|
||||||
openSelectedVisible: openSelectedVisible,
|
openSelectedVisible: openSelectedVisible,
|
||||||
dismissWarning: dismissWarning,
|
dismissWarning: dismissWarning,
|
||||||
|
// Project-mode entry points (also tested directly).
|
||||||
|
detectMode: detectMode,
|
||||||
|
renderProjectMode: renderProjectMode,
|
||||||
// Test-only: override the navigation function (avoids the messy
|
// Test-only: override the navigation function (avoids the messy
|
||||||
// browser-locked-down state of window.location).
|
// browser-locked-down state of window.location).
|
||||||
_setNavigate: function(fn) { navigate = fn; }
|
_setNavigate: function(fn) { navigate = fn; }
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="landing-main">
|
<main id="landingMain" class="landing-main">
|
||||||
|
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
||||||
|
<div id="pickerView">
|
||||||
<!-- Welcome / hero -->
|
<!-- Welcome / hero -->
|
||||||
<section class="landing-hero">
|
<section class="landing-hero">
|
||||||
<h1>Welcome to the ZDDC Archive</h1>
|
<h1>Welcome to the ZDDC Archive</h1>
|
||||||
|
|
@ -90,6 +92,67 @@
|
||||||
<div class="project-list-loading">Loading projects…</div>
|
<div class="project-list-loading">Loading projects…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /pickerView -->
|
||||||
|
|
||||||
|
<!-- Project mode (/<project>). Stage cards + MDL section. Shown
|
||||||
|
by landing.js when location.pathname is a single segment. -->
|
||||||
|
<div id="projectView" class="hidden">
|
||||||
|
<h1 id="projectTitle" class="project-title">
|
||||||
|
<span id="projectName"></span>
|
||||||
|
<span class="project-title__subtle">— project workspace</span>
|
||||||
|
</h1>
|
||||||
|
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
|
||||||
|
|
||||||
|
<div class="stages">
|
||||||
|
<a class="stage-card" id="stageArchive">
|
||||||
|
<h3>Archive</h3>
|
||||||
|
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" id="stageWorking">
|
||||||
|
<h3>Working</h3>
|
||||||
|
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" id="stageStaging">
|
||||||
|
<h3>Staging</h3>
|
||||||
|
<p>Outbound transmittals being prepared for issue.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" id="stageReviewing">
|
||||||
|
<h3>Reviewing</h3>
|
||||||
|
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a id="browseAllLink" class="browse-link">Browse all files →</a></p>
|
||||||
|
|
||||||
|
<h2>Master Deliverables List (MDL)</h2>
|
||||||
|
<p>Each counterparty in the archive has an MDL — an editable
|
||||||
|
table of expected deliverables. The default columns mirror
|
||||||
|
the ZDDC tracking-number components (<code>originator</code>,
|
||||||
|
<code>phase</code>, <code>project</code>, <code>area</code>,
|
||||||
|
<code>discipline</code>, <code>type</code>,
|
||||||
|
<code>sequence</code>, <code>suffix</code>) plus
|
||||||
|
<code>title</code>, <code>plannedRevision</code>,
|
||||||
|
<code>plannedDate</code>, <code>status</code>, and
|
||||||
|
<code>owner</code>.</p>
|
||||||
|
|
||||||
|
<p><strong>To edit the MDL for any party:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Open the project archive: <a id="archiveBrowseLink"></a></li>
|
||||||
|
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
|
||||||
|
<li>Click <code>mdl</code> inside the party folder</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div id="partyListSection">
|
||||||
|
<!-- Populated by JS when archive/ enumeration succeeds.
|
||||||
|
Either a "direct links" block with <ul.party-list> or a
|
||||||
|
"no parties yet" fallback. -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>To customize the columns or schema for a specific party, drop
|
||||||
|
a <code>table.yaml</code> and <code>form.yaml</code> into
|
||||||
|
<code>archive/<party>/mdl/</code>. Operator-supplied
|
||||||
|
files override the embedded defaults entirely.</p>
|
||||||
|
</div><!-- /projectView -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Help Panel -->
|
<!-- Help Panel -->
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const HTML_PATH = path.resolve('landing/dist/index.html');
|
const HTML_PATH = path.resolve('landing/dist/index.html');
|
||||||
|
|
@ -227,6 +229,93 @@ test.describe('Landing page', () => {
|
||||||
expect(navTo).not.toContain('?projects=');
|
expect(navTo).not.toContain('?projects=');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('project mode: detectMode classifies URLs correctly', async ({ page }) => {
|
||||||
|
await loadLandingWithProjects(page, []);
|
||||||
|
const result = await page.evaluate(() => ({
|
||||||
|
picker: window.LandingApp.detectMode === undefined ? 'no-fn' : null,
|
||||||
|
}));
|
||||||
|
// Sanity: the project-mode entry points are exposed.
|
||||||
|
expect(result.picker).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// project-mode tests need a real http(s) origin so location.pathname can be
|
||||||
|
// /<project>. Spin up a tiny in-process server that serves the same
|
||||||
|
// landing HTML at any path.
|
||||||
|
test.describe('Landing project mode', () => {
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const html = fs.readFileSync(HTML_PATH, 'utf8');
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
// The page itself fetches /<project>/archive/ for the
|
||||||
|
// party listing. Stub that with a small JSON listing so the
|
||||||
|
// direct-link section renders. Anything else returns the
|
||||||
|
// landing HTML.
|
||||||
|
if (req.url === '/Project-1/archive/' &&
|
||||||
|
(req.headers.accept || '').includes('json')) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify([
|
||||||
|
{ name: 'PartyA/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyA/' },
|
||||||
|
{ name: 'PartyB/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyB/' },
|
||||||
|
{ name: '.hidden/', is_dir: true, size: 0, url: '/Project-1/Archive/.hidden/' },
|
||||||
|
]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(html);
|
||||||
|
});
|
||||||
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (server) await new Promise(r => server.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders project workspace at /<project>', async ({ page }) => {
|
||||||
|
await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' });
|
||||||
|
await page.waitForSelector('#projectView:not(.hidden)', { timeout: 5000 });
|
||||||
|
|
||||||
|
// Project name surfaces in the H1 + the document title.
|
||||||
|
await expect(page.locator('#projectName')).toHaveText('Project-1');
|
||||||
|
expect(await page.title()).toBe('Project-1 — ZDDC');
|
||||||
|
|
||||||
|
// Picker view is hidden.
|
||||||
|
await expect(page.locator('#pickerView')).toBeHidden();
|
||||||
|
|
||||||
|
// Four stage cards with the expected hrefs.
|
||||||
|
const stageHrefs = await page.evaluate(() => ({
|
||||||
|
archive: document.getElementById('stageArchive').getAttribute('href'),
|
||||||
|
working: document.getElementById('stageWorking').getAttribute('href'),
|
||||||
|
staging: document.getElementById('stageStaging').getAttribute('href'),
|
||||||
|
reviewing: document.getElementById('stageReviewing').getAttribute('href'),
|
||||||
|
}));
|
||||||
|
expect(stageHrefs).toEqual({
|
||||||
|
archive: '/Project-1/archive',
|
||||||
|
working: '/Project-1/working',
|
||||||
|
staging: '/Project-1/staging',
|
||||||
|
reviewing: '/Project-1/reviewing',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists existing parties as direct MDL links', async ({ page }) => {
|
||||||
|
await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' });
|
||||||
|
// Wait for the async party fetch to populate.
|
||||||
|
await page.waitForSelector('.party-list a', { timeout: 5000 });
|
||||||
|
|
||||||
|
const links = await page.locator('.party-list a').allTextContents();
|
||||||
|
expect(links.sort()).toEqual(['PartyA MDL →', 'PartyB MDL →']);
|
||||||
|
// Hidden dot-prefixed entry was filtered out.
|
||||||
|
const hrefs = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('.party-list a')].map(a => a.getAttribute('href'))
|
||||||
|
);
|
||||||
|
expect(hrefs).toContain('/Project-1/Archive/PartyA/mdl/');
|
||||||
|
expect(hrefs).toContain('/Project-1/Archive/PartyB/mdl/');
|
||||||
|
expect(hrefs.some(h => h.includes('.hidden'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy presets are migrated to groups on first load', async ({ page }) => {
|
test('legacy presets are migrated to groups on first load', async ({ page }) => {
|
||||||
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
|
||||||
// Seed legacy presets and clear the new key.
|
// Seed legacy presets and clear the new key.
|
||||||
|
|
|
||||||
|
|
@ -960,16 +960,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Project root (depth-1 dir, no trailing slash) gets a synthetic
|
// Project root (depth-1 dir, no trailing slash) serves the
|
||||||
// landing page with the four lifecycle-stage cards + MDL
|
// landing tool, which detects mode='project' from
|
||||||
// instructions. With trailing slash, the project falls through to
|
// location.pathname and renders the lifecycle-stage cards +
|
||||||
// the regular browse listing.
|
// MDL section. Same single-file SPA as the deployment-root
|
||||||
|
// project picker — one tool, two URL shapes. With trailing
|
||||||
|
// slash, the project falls through to the regular browse
|
||||||
|
// listing.
|
||||||
if !strings.HasSuffix(urlPath, "/") &&
|
if !strings.HasSuffix(urlPath, "/") &&
|
||||||
(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
handler.IsProjectRootURL(urlPath) {
|
handler.IsProjectRootURL(urlPath) {
|
||||||
project := strings.TrimPrefix(urlPath, "/")
|
if appsSrv != nil {
|
||||||
handler.ServeProjectLanding(cfg, w, r, project)
|
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
|
||||||
return
|
appsSrv.Serve(w, r, "landing", chain, absPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,25 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsProjectRootURL reports whether urlPath names a project root —
|
// IsProjectRootURL reports whether urlPath names a project root —
|
||||||
// exactly one path segment, no trailing slash. Used by the dispatcher
|
// exactly one path segment, no trailing slash. Used by the dispatcher
|
||||||
// to route /<project> (with no trailing slash) to ServeProjectLanding
|
// to route /<project> (no trailing slash) to the landing tool's
|
||||||
// instead of 301'ing to the slash form.
|
// project-workspace mode rather than the historical 301-to-slash.
|
||||||
//
|
//
|
||||||
// Examples:
|
// Examples:
|
||||||
//
|
//
|
||||||
// "/Project-1" → true
|
// "/Project-1" → true
|
||||||
// "/Project-1/" → false (trailing slash → directory listing)
|
// "/Project-1/" → false (trailing slash → directory listing)
|
||||||
// "/Project-1/x" → false (deeper)
|
// "/Project-1/x" → false (deeper)
|
||||||
// "/" → false (deployment root, served by landing tool)
|
// "/" → false (deployment root)
|
||||||
// "" → false
|
// "" → false
|
||||||
|
//
|
||||||
|
// The actual page rendering lives client-side in landing/js/landing.js
|
||||||
|
// (mode='project'); this server-side predicate only decides where to
|
||||||
|
// route the request.
|
||||||
func IsProjectRootURL(urlPath string) bool {
|
func IsProjectRootURL(urlPath string) bool {
|
||||||
if urlPath == "" || urlPath == "/" {
|
if urlPath == "" || urlPath == "/" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -35,263 +30,3 @@ func IsProjectRootURL(urlPath string) bool {
|
||||||
trimmed := strings.TrimPrefix(urlPath, "/")
|
trimmed := strings.TrimPrefix(urlPath, "/")
|
||||||
return !strings.Contains(trimmed, "/")
|
return !strings.Contains(trimmed, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// projectLandingTmpl is the inline template for /<project> (no slash).
|
|
||||||
// It's a simple navigation page — four canonical-stage cards, a link
|
|
||||||
// to the full file browser, and instructions for editing the MDL,
|
|
||||||
// listing any parties already present in archive/.
|
|
||||||
var projectLandingTmpl = template.Must(template.New("projectLanding").Parse(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{.Project}} — ZDDC</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #2a5a8a; --primary-hover: #1d4060;
|
|
||||||
--bg: #ffffff; --bg-secondary: #f8f9fa; --bg-hover: #f0f4f8;
|
|
||||||
--text: #212529; --text-muted: #6c757d;
|
|
||||||
--border: #dee2e6; --radius: 4px;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--primary: #5fa8e0; --primary-hover: #74b6e6;
|
|
||||||
--bg: #1e1e1e; --bg-secondary: #252526; --bg-hover: #2d2d30;
|
|
||||||
--text: #d4d4d4; --text-muted: #9d9d9d;
|
|
||||||
--border: #3e3e42;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
|
||||||
html, body { margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
color: var(--text); background: var(--bg-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
header.app-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 0.5rem 1rem; background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
header .app-header__logo {
|
|
||||||
width: 26px; height: 26px; display: inline-block; vertical-align: middle;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
header .app-header__logo-link {
|
|
||||||
display: inline-flex; align-items: center; text-decoration: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: opacity 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
header .app-header__logo-link:hover .app-header__logo,
|
|
||||||
header .app-header__logo-link:focus-visible .app-header__logo {
|
|
||||||
opacity: 0.82;
|
|
||||||
}
|
|
||||||
header .app-header__logo-link:focus-visible {
|
|
||||||
outline: 2px solid var(--primary); outline-offset: 2px;
|
|
||||||
}
|
|
||||||
header .title-line { font-size: 1.05rem; font-weight: 600; }
|
|
||||||
header .crumb { color: var(--text-muted); font-size: 0.85rem; }
|
|
||||||
main {
|
|
||||||
max-width: 880px; margin: 2rem auto; padding: 0 1.25rem;
|
|
||||||
}
|
|
||||||
h1 { font-size: 1.6rem; margin: 0 0 0.25rem; font-weight: 600; }
|
|
||||||
h1 .subtle { color: var(--text-muted); font-weight: normal; font-size: 0.9rem; }
|
|
||||||
h2 {
|
|
||||||
font-size: 1.1rem; margin: 2.25rem 0 0.5rem;
|
|
||||||
padding-bottom: 0.3rem; border-bottom: 1px solid var(--border);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
p { margin: 0.5rem 0 1rem; }
|
|
||||||
.lead { color: var(--text-muted); margin-bottom: 1.5rem; }
|
|
||||||
.stages {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 0.85rem;
|
|
||||||
margin: 1rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
.stage-card {
|
|
||||||
display: block; padding: 1rem 1.1rem;
|
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius); text-decoration: none; color: var(--text);
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
|
||||||
}
|
|
||||||
.stage-card:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
.stage-card:active { transform: translateY(1px); }
|
|
||||||
.stage-card h3 {
|
|
||||||
margin: 0 0 0.3rem; font-size: 1rem; color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.stage-card p { margin: 0; color: var(--text-muted); font-size: 0.875rem; }
|
|
||||||
.browse-link {
|
|
||||||
display: inline-block; margin-top: 0.25rem; color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.browse-link:hover { text-decoration: underline; }
|
|
||||||
ol { padding-left: 1.5rem; }
|
|
||||||
ol li { margin-bottom: 0.4rem; }
|
|
||||||
code {
|
|
||||||
font-family: ui-monospace, "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
|
|
||||||
background: var(--bg-secondary); padding: 0.1em 0.35em;
|
|
||||||
border-radius: 3px; font-size: 0.86em;
|
|
||||||
}
|
|
||||||
.party-list { padding-left: 1.5rem; margin: 0.4rem 0 1rem; }
|
|
||||||
.party-list li { margin-bottom: 0.25rem; }
|
|
||||||
.party-list a { color: var(--primary); text-decoration: none; }
|
|
||||||
.party-list a:hover { text-decoration: underline; }
|
|
||||||
.none-yet { color: var(--text-muted); font-style: italic; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="app-header">
|
|
||||||
<div>
|
|
||||||
<a class="app-header__logo-link" href="/" title="ZDDC home" aria-label="ZDDC home">
|
|
||||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
|
||||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
|
||||||
<g fill="#fff">
|
|
||||||
<rect x="14" y="18" width="36" height="7"/>
|
|
||||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
|
||||||
<rect x="14" y="43" width="36" height="7"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<span class="title-line">{{.Project}}</span>
|
|
||||||
</div>
|
|
||||||
<span class="crumb">ZDDC project workspace</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<h1>{{.Project}} <span class="subtle">— project workspace</span></h1>
|
|
||||||
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
|
|
||||||
|
|
||||||
<div class="stages">
|
|
||||||
<a class="stage-card" href="/{{.ProjectURL}}/archive">
|
|
||||||
<h3>Archive</h3>
|
|
||||||
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
|
|
||||||
</a>
|
|
||||||
<a class="stage-card" href="/{{.ProjectURL}}/working">
|
|
||||||
<h3>Working</h3>
|
|
||||||
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
|
|
||||||
</a>
|
|
||||||
<a class="stage-card" href="/{{.ProjectURL}}/staging">
|
|
||||||
<h3>Staging</h3>
|
|
||||||
<p>Outbound transmittals being prepared for issue.</p>
|
|
||||||
</a>
|
|
||||||
<a class="stage-card" href="/{{.ProjectURL}}/reviewing">
|
|
||||||
<h3>Reviewing</h3>
|
|
||||||
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><a class="browse-link" href="/{{.ProjectURL}}/">Browse all files →</a></p>
|
|
||||||
|
|
||||||
<h2>Master Deliverables List (MDL)</h2>
|
|
||||||
<p>Each counterparty in the archive has an MDL — an editable table
|
|
||||||
of expected deliverables. The default columns mirror the ZDDC
|
|
||||||
tracking-number components (<code>originator</code>, <code>phase</code>,
|
|
||||||
<code>project</code>, <code>area</code>, <code>discipline</code>,
|
|
||||||
<code>type</code>, <code>sequence</code>, <code>suffix</code>) plus
|
|
||||||
<code>title</code>, <code>plannedRevision</code>,
|
|
||||||
<code>plannedDate</code>, <code>status</code>, and <code>owner</code>.</p>
|
|
||||||
|
|
||||||
<p><strong>To edit the MDL for any party:</strong></p>
|
|
||||||
<ol>
|
|
||||||
<li>Open the project archive: <a href="/{{.ProjectURL}}/archive/"><code>/{{.ProjectURL}}/archive/</code></a></li>
|
|
||||||
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
|
|
||||||
<li>Click <code>mdl</code> inside the party folder</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
{{if .Parties}}
|
|
||||||
<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>
|
|
||||||
<ul class="party-list">
|
|
||||||
{{range .Parties}}
|
|
||||||
<li><a href="/{{$.ProjectURL}}/{{$.ArchiveSeg}}/{{.URLName}}/mdl/">{{.DisplayName}} MDL →</a></li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
|
||||||
<p class="none-yet">No party folders yet. The MDL view auto-renders at
|
|
||||||
any <code>archive/<party>/mdl/</code> URL, even when the folder
|
|
||||||
doesn't exist on disk — so you can start editing an MDL before any
|
|
||||||
transmittals have been exchanged.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p>To customize the columns or schema for a specific party, drop a
|
|
||||||
<code>table.yaml</code> and <code>form.yaml</code> into
|
|
||||||
<code>archive/<party>/mdl/</code>. Operator-supplied files
|
|
||||||
override the embedded defaults entirely.</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`))
|
|
||||||
|
|
||||||
// projectLandingData is the template input.
|
|
||||||
type projectLandingData struct {
|
|
||||||
Project string
|
|
||||||
ProjectURL string // url-escaped Project, for use in href values
|
|
||||||
ArchiveSeg string // on-disk casing of "archive" (Archive vs archive)
|
|
||||||
Parties []partyEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type partyEntry struct {
|
|
||||||
DisplayName string // on-disk name (e.g. "PartyA")
|
|
||||||
URLName string // url-escaped variant
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeProjectLanding renders the project root navigation page at
|
|
||||||
// /<project> (no trailing slash). Lists the four canonical lifecycle
|
|
||||||
// stages as cards, links into the full browse view, and provides
|
|
||||||
// instructions for editing the per-party MDL with direct links to any
|
|
||||||
// parties already present under archive/.
|
|
||||||
//
|
|
||||||
// ACL: the dispatcher already gates this entry by the project's
|
|
||||||
// .zddc cascade before calling here. No additional check needed.
|
|
||||||
func ServeProjectLanding(cfg config.Config, w http.ResponseWriter, r *http.Request, project string) {
|
|
||||||
projectAbs := filepath.Join(cfg.Root, project)
|
|
||||||
|
|
||||||
// On-disk casing of archive/ — preserve in URL hrefs so links
|
|
||||||
// don't bounce through the URL-canonicalisation layer.
|
|
||||||
archiveSeg, _ := zddc.ResolveCanonical(projectAbs, "archive")
|
|
||||||
if archiveSeg == "" {
|
|
||||||
archiveSeg = "archive"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enumerate parties under archive/<party>/. Failures here are
|
|
||||||
// non-fatal — the page just renders without the direct-link list.
|
|
||||||
var parties []partyEntry
|
|
||||||
if archiveSeg != "" {
|
|
||||||
archiveAbs := filepath.Join(projectAbs, archiveSeg)
|
|
||||||
if entries, err := os.ReadDir(archiveAbs); err == nil {
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := e.Name()
|
|
||||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parties = append(parties, partyEntry{
|
|
||||||
DisplayName: name,
|
|
||||||
URLName: url.PathEscape(name),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(parties, func(i, j int) bool {
|
|
||||||
return parties[i].DisplayName < parties[j].DisplayName
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data := projectLandingData{
|
|
||||||
Project: project,
|
|
||||||
ProjectURL: url.PathEscape(project),
|
|
||||||
ArchiveSeg: url.PathEscape(archiveSeg),
|
|
||||||
Parties: parties,
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-store") // recomputes party list per hit
|
|
||||||
if err := projectLandingTmpl.Execute(w, data); err != nil {
|
|
||||||
// Headers already flushed; nothing to do beyond log.
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsProjectRootURL(t *testing.T) {
|
func TestIsProjectRootURL(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
|
|
@ -26,100 +17,3 @@ func TestIsProjectRootURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeProjectLanding(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
// Need .zddc on disk for the resolver to be happy at the root.
|
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Project with two parties under archive/ (PascalCase to exercise
|
|
||||||
// the case-insensitive archive resolver) and one orphaned dot-prefixed
|
|
||||||
// dir that should be filtered out of the party list.
|
|
||||||
for _, sub := range []string{
|
|
||||||
"Project-1/Archive/PartyA",
|
|
||||||
"Project-1/Archive/PartyB",
|
|
||||||
"Project-1/Archive/.hidden",
|
|
||||||
} {
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := config.Config{Root: root}
|
|
||||||
|
|
||||||
t.Run("renders project name + stage cards", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeProjectLanding(cfg, rec, req, "Project-1")
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status=%d", rec.Code)
|
|
||||||
}
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
// Page identifies the project and includes the four stages.
|
|
||||||
for _, want := range []string{
|
|
||||||
"Project-1",
|
|
||||||
"<h3>Archive</h3>",
|
|
||||||
"<h3>Working</h3>",
|
|
||||||
"<h3>Staging</h3>",
|
|
||||||
"<h3>Reviewing</h3>",
|
|
||||||
"Master Deliverables List",
|
|
||||||
`href="/Project-1/working"`,
|
|
||||||
`href="/Project-1/archive/"`,
|
|
||||||
// Logo wraps to the deployment root — same convention as
|
|
||||||
// shared/logo.js applies in tools (which would route here
|
|
||||||
// to /Project-1, the project landing). On the project
|
|
||||||
// landing itself, "next up" is the deployment root.
|
|
||||||
`<a class="app-header__logo-link" href="/"`,
|
|
||||||
`class="app-header__logo"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("body missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("lists existing parties as direct MDL links", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeProjectLanding(cfg, rec, req, "Project-1")
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
// Both parties surfaced; on-disk casing preserved in the URL.
|
|
||||||
if !strings.Contains(body, `href="/Project-1/Archive/PartyA/mdl/"`) {
|
|
||||||
t.Errorf("body missing PartyA MDL link")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `href="/Project-1/Archive/PartyB/mdl/"`) {
|
|
||||||
t.Errorf("body missing PartyB MDL link")
|
|
||||||
}
|
|
||||||
// Dot-prefixed entries filtered out.
|
|
||||||
if strings.Contains(body, ".hidden") {
|
|
||||||
t.Errorf("body should not list .hidden directory")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no parties yet → falls back to generic instruction", func(t *testing.T) {
|
|
||||||
bare := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(bare, ".zddc"),
|
|
||||||
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Join(bare, "Fresh"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
bareCfg := config.Config{Root: bare}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/Fresh", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeProjectLanding(bareCfg, rec, req, "Fresh")
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
// Falls through to the "no party folders yet" copy.
|
|
||||||
if !strings.Contains(body, "No party folders yet") {
|
|
||||||
t.Errorf("body missing fresh-project fallback copy")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue