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:
parent
319a3c0ce7
commit
d5638e9697
4 changed files with 165 additions and 67 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/<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>';
|
||||
// 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/<party>/</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
|
||||
|
|
|
|||
|
|
@ -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/<party>/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/<party>/mdl/</code>. Operator-supplied
|
||||
files override the embedded defaults entirely.</p>
|
||||
</div><!-- /projectView -->
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue