feat(landing): MDL card with party dropdown on project view

The Master Deliverables List section was a long prose block ("To edit
the MDL: 1. open the archive, 2. click into a party folder, 3. click
mdl…") followed by a bullet list of party links — visually
inconsistent with the four stage cards above it.

Replaced by a fifth card in the .stages grid styled like the others:
heading + short description + an inline select + Open button. The
select populates from the same fetchParties() helper that backed the
old <ul.party-list>; selecting a party + clicking Open navigates to
/<project>/archive/<party>/mdl/.

Empty/error states:
  - No parties yet: select shows "(no party folders yet)"; hint copy
    expands to explain the URL-based fallback (zddc-server still
    auto-renders archive/<party>/mdl/ even when the folder is missing).
  - Network error: select shows "(could not enumerate parties)"; user
    can navigate via the URL bar.

Updated landing.spec.js — the old "lists existing parties as direct
MDL links" test now asserts on #mdlPartySelect contents + click-to-nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 11:25:14 -05:00
parent 319a3c0ce7
commit d5638e9697
4 changed files with 165 additions and 67 deletions

View file

@ -451,6 +451,60 @@ body {
font-size: 0.875rem;
}
/* MDL card variant: same outer styling as a stage card but contains
an interactive control (party <select> + Open button) instead of
navigating on click of the whole card. The :hover lift applies
regardless. */
.stage-card--mdl {
cursor: default;
}
.stage-card--mdl:active {
transform: none;
}
.stage-card__action {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.65rem;
}
.mdl-party-select {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.5rem;
font-family: var(--font);
font-size: 0.9rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text);
}
.mdl-party-select:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 2px rgba(95, 168, 224, 0.25);
}
.mdl-party-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.stage-card__hint {
margin: 0.65rem 0 0 !important;
font-size: 0.78rem !important;
color: var(--text-muted) !important;
line-height: 1.4;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.browse-link {
display: inline-block;
margin-top: 0.25rem;

View file

@ -684,43 +684,90 @@
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;
// MDL card. Same shape as the stage cards above, but
// interactive: a <select> populated with party folders and an
// Open button that opens the chosen party's MDL. The view
// auto-renders at any archive/<party>/mdl/ URL even when the
// folder doesn't exist on disk (zddc-server commit 3fc3717),
// so we offer the operator-supplied party list directly AND
// a "type a new party name" affordance via a free-text last
// option.
var mdlSelect = document.getElementById('mdlPartySelect');
var mdlOpenBtn = document.getElementById('mdlOpenBtn');
var mdlHint = document.getElementById('mdlHint');
if (!mdlSelect || !mdlOpenBtn) return;
// Wire the Open button regardless of fetch outcome — even if
// party enumeration fails, an operator can still navigate by
// typing the party folder name in the URL bar.
function openSelectedMdl() {
var party = mdlSelect.value;
if (!party) return;
var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl/';
window.location.assign(url);
}
mdlOpenBtn.addEventListener('click', openSelectedMdl);
mdlSelect.addEventListener('change', function () {
mdlOpenBtn.disabled = !mdlSelect.value;
});
// Enter inside the select also opens.
mdlSelect.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
openSelectedMdl();
}
});
var parties = await fetchParties(p);
// Repopulate the select. mdlSelect starts with a single
// "Loading…" option; replace its contents either way.
mdlSelect.innerHTML = '';
if (parties == null) {
// Network error or unauthenticated — show neither list nor
// explicit "none" message. The page is still usable.
partySection.innerHTML = '';
// Network error or unauthenticated. Leave the select
// disabled but visible; user can still navigate via URL.
var optErr = document.createElement('option');
optErr.value = '';
optErr.textContent = '(could not enumerate parties)';
mdlSelect.appendChild(optErr);
mdlSelect.disabled = true;
mdlOpenBtn.disabled = true;
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/&lt;party&gt;/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>';
// No parties yet, but per the hint the URL still works for
// any party name. Give a placeholder option that disables
// Open until the user has typed something — except we
// don't have a text input. So just say "(none yet)" and
// disable. Operator can still navigate via the URL bar.
var optNone = document.createElement('option');
optNone.value = '';
optNone.textContent = '(no party folders yet)';
mdlSelect.appendChild(optNone);
mdlSelect.disabled = true;
mdlOpenBtn.disabled = true;
if (mdlHint) {
mdlHint.innerHTML =
'No <code>archive/&lt;party&gt;/</code> folders yet. The MDL view still '
+ 'auto-renders at any such URL, even before the folder exists — type a '
+ 'party name into the URL bar (or wait for the first transmittal) to start editing.';
}
return;
}
var html = '<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>'
+ '<ul class="party-list">';
// Populate the select with each party.
var optPlaceholder = document.createElement('option');
optPlaceholder.value = '';
optPlaceholder.textContent = 'Choose a party…';
mdlSelect.appendChild(optPlaceholder);
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>';
var opt = document.createElement('option');
opt.value = parties[j].name;
opt.textContent = parties[j].name;
mdlSelect.appendChild(opt);
}
html += '</ul>';
partySection.innerHTML = html;
mdlSelect.disabled = false;
// Open button stays disabled until the user picks something.
mdlOpenBtn.disabled = true;
}
// Returns an array of {name, url} for each party folder in the

View file

@ -162,38 +162,27 @@
<h3>Reviewing</h3>
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
</a>
<!-- MDL card. Visually matches the four stage cards above but
is interactive rather than a plain link: pick a party from
the select, then Open. -->
<div class="stage-card stage-card--mdl" id="stageMdl">
<h3>Master Deliverables List</h3>
<p>The editable list of expected deliverables for each counterparty.</p>
<div class="stage-card__action">
<label class="visually-hidden" for="mdlPartySelect">Party</label>
<select id="mdlPartySelect" class="mdl-party-select" disabled>
<option value="">Loading parties…</option>
</select>
<button id="mdlOpenBtn" class="btn btn-primary btn-sm" disabled>Open MDL</button>
</div>
<p class="stage-card__hint" id="mdlHint">
The MDL view renders even when <code>archive/&lt;party&gt;/mdl/</code> doesn't yet exist —
you can start editing before any transmittals have been exchanged.
</p>
</div>
</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/&lt;party&gt;/mdl/</code>. Operator-supplied
files override the embedded defaults entirely.</p>
</div><!-- /projectView -->
</main>

View file

@ -300,20 +300,28 @@ test.describe('Landing project mode', () => {
});
});
test('lists existing parties as direct MDL links', async ({ page }) => {
test('MDL card lists existing parties in a dropdown', 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 });
// Wait for the async party fetch to populate the select.
await page.waitForFunction(() => {
const sel = document.getElementById('mdlPartySelect');
return sel && !sel.disabled && sel.options.length > 1;
}, { 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);
const options = await page.locator('#mdlPartySelect option').allTextContents();
// First option is the placeholder; the rest are party names.
expect(options[0]).toBe('Choose a party…');
expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']);
// Selecting a party enables the Open button; clicking it navigates
// to the canonical /<project>/archive/<party>/mdl/ URL.
await page.selectOption('#mdlPartySelect', 'PartyA');
await expect(page.locator('#mdlOpenBtn')).toBeEnabled();
const [navUrl] = await Promise.all([
page.waitForURL(/\/Project-1\/archive\/PartyA\/mdl\/$/, { timeout: 5000 }).then(() => page.url()),
page.click('#mdlOpenBtn'),
]);
expect(navUrl).toMatch(/\/Project-1\/archive\/PartyA\/mdl\/$/);
});
test('legacy presets are migrated to groups on first load', async ({ page }) => {