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; 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 { .browse-link {
display: inline-block; display: inline-block;
margin-top: 0.25rem; margin-top: 0.25rem;

View file

@ -684,43 +684,90 @@
browseAll.setAttribute('href', '/' + p + '/'); browseAll.setAttribute('href', '/' + p + '/');
browseAll.textContent = 'Browse all files →'; 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 // MDL card. Same shape as the stage cards above, but
// no-parties-yet fallback. We try /<project>/archive/ — the // interactive: a <select> populated with party folders and an
// server returns the listing in either lowercase or PascalCase // Open button that opens the chosen party's MDL. The view
// form; either yields the same JSON shape via case-insensitive // auto-renders at any archive/<party>/mdl/ URL even when the
// URL canonicalization. // folder doesn't exist on disk (zddc-server commit 3fc3717),
var partySection = document.getElementById('partyListSection'); // so we offer the operator-supplied party list directly AND
if (!partySection) return; // 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); 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) { if (parties == null) {
// Network error or unauthenticated — show neither list nor // Network error or unauthenticated. Leave the select
// explicit "none" message. The page is still usable. // disabled but visible; user can still navigate via URL.
partySection.innerHTML = ''; var optErr = document.createElement('option');
optErr.value = '';
optErr.textContent = '(could not enumerate parties)';
mdlSelect.appendChild(optErr);
mdlSelect.disabled = true;
mdlOpenBtn.disabled = true;
return; return;
} }
if (parties.length === 0) { if (parties.length === 0) {
partySection.innerHTML = // No parties yet, but per the hint the URL still works for
'<p class="party-list-none-yet">No party folders yet. The MDL view auto-renders at any ' // any party name. Give a placeholder option that disables
+ '<code>archive/&lt;party&gt;/mdl/</code> URL, even when the folder doesn\'t exist on ' // Open until the user has typed something — except we
+ 'disk — so you can start editing an MDL before any transmittals have been exchanged.</p>'; // 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; return;
} }
var html = '<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>' // Populate the select with each party.
+ '<ul class="party-list">'; var optPlaceholder = document.createElement('option');
optPlaceholder.value = '';
optPlaceholder.textContent = 'Choose a party…';
mdlSelect.appendChild(optPlaceholder);
for (var j = 0; j < parties.length; j++) { for (var j = 0; j < parties.length; j++) {
var name = parties[j].name; var opt = document.createElement('option');
var url = parties[j].url; // server-provided absolute URL opt.value = parties[j].name;
html += '<li><a href="' + url + 'mdl/">' + escapeHtml(name) + ' MDL →</a></li>'; opt.textContent = parties[j].name;
mdlSelect.appendChild(opt);
} }
html += '</ul>'; mdlSelect.disabled = false;
partySection.innerHTML = html; // Open button stays disabled until the user picks something.
mdlOpenBtn.disabled = true;
} }
// Returns an array of {name, url} for each party folder in the // Returns an array of {name, url} for each party folder in the

View file

@ -162,38 +162,27 @@
<h3>Reviewing</h3> <h3>Reviewing</h3>
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p> <p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
</a> </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> </div>
<p><a id="browseAllLink" class="browse-link">Browse all files →</a></p> <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 --> </div><!-- /projectView -->
</main> </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' }); await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' });
// Wait for the async party fetch to populate. // Wait for the async party fetch to populate the select.
await page.waitForSelector('.party-list a', { timeout: 5000 }); 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(); const options = await page.locator('#mdlPartySelect option').allTextContents();
expect(links.sort()).toEqual(['PartyA MDL →', 'PartyB MDL →']); // First option is the placeholder; the rest are party names.
// Hidden dot-prefixed entry was filtered out. expect(options[0]).toBe('Choose a party…');
const hrefs = await page.evaluate(() => expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']);
[...document.querySelectorAll('.party-list a')].map(a => a.getAttribute('href'))
); // Selecting a party enables the Open button; clicking it navigates
expect(hrefs).toContain('/Project-1/Archive/PartyA/mdl/'); // to the canonical /<project>/archive/<party>/mdl/ URL.
expect(hrefs).toContain('/Project-1/Archive/PartyB/mdl/'); await page.selectOption('#mdlPartySelect', 'PartyA');
expect(hrefs.some(h => h.includes('.hidden'))).toBe(false); 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 }) => { test('legacy presets are migrated to groups on first load', async ({ page }) => {