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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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/<party>/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/<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;
|
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
|
||||||
|
|
|
||||||
|
|
@ -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/<party>/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/<party>/mdl/</code>. Operator-supplied
|
|
||||||
files override the embedded defaults entirely.</p>
|
|
||||||
</div><!-- /projectView -->
|
</div><!-- /projectView -->
|
||||||
</main>
|
</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' });
|
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 }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue