feat(landing): "+ New project" on the picker for authorized users
The server already exposes everything: GET /.profile/access reports
can_create_project (the exact gate POST /.profile/projects enforces), and the
POST creates the folder + seeds its .zddc (creator as admin, title, role
memberships). This wires the landing picker to it:
- On load the picker fetches /.profile/access; if can_create_project, it reveals
a "+ New project" button next to the Projects heading (hidden otherwise, so we
never dangle an affordance the server would 404).
- The button opens a dialog mirroring the profile page's create form — name,
title, and member lists for admins / document controllers / project team /
guests, plus an advanced ACL-permissions list. It POSTs to /.profile/projects
and, on success, closes and refreshes the project list so the new project
appears. Field errors (bad name, 409 duplicate) surface inline.
Server-only by nature (needs the endpoints + auth); offline the access fetch
fails and the button stays hidden.
Also fix a stale landing test: working/staging/reviewing stage links carry a
trailing slash since ec9c9c7 (virtual aggregators 302 on <dir>/); the
assertion still expected the slashless form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f8b4e4aaa
commit
70c6946e56
4 changed files with 305 additions and 3 deletions
|
|
@ -518,3 +518,110 @@ body {
|
|||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── New-project dialog ──────────────────────────────────────────────────── */
|
||||
.np-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.np-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.np-modal__dialog {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
margin: auto;
|
||||
}
|
||||
.np-modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.np-modal__head h2 { margin: 0; font-size: 1.25rem; }
|
||||
.np-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
.np-modal__close:hover { color: var(--text); }
|
||||
.np-help { color: var(--text-muted); font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.np-field { display: block; margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--text-secondary, var(--text-muted)); }
|
||||
.np-field input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary, var(--bg));
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.np-err { display: block; color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
.np-grouphdr { font-size: 0.95em; margin: 1rem 0 0.3rem; font-weight: 600; }
|
||||
.np-sub { font-weight: 400; color: var(--text-muted); font-size: 0.8rem; }
|
||||
.np-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.4rem; }
|
||||
.np-row { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.np-row__input { flex: 1; }
|
||||
.np-row__verbs { flex: 0 0 8rem; }
|
||||
.np-row input {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary, var(--bg));
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.np-del {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.np-del:hover { color: var(--danger); border-color: var(--danger); }
|
||||
.np-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
.np-add:hover { border-color: var(--primary); }
|
||||
.np-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -828,6 +828,133 @@
|
|||
|
||||
// ── Bootstrap ────────────────────────────────────────────────────────────
|
||||
|
||||
// ── New project (server-only; gated on can_create_project) ───────────────
|
||||
// The /.profile endpoints are rooted at the server root, which is where the
|
||||
// picker lives, so the project base doubles as the API base.
|
||||
function apiBase() { return location.origin + location.pathname.replace(/\/[^\/]*$/, '/'); }
|
||||
|
||||
async function fetchAccess() {
|
||||
try {
|
||||
var resp = await fetch(apiBase() + '.profile/access', {
|
||||
headers: { 'Accept': 'application/json' }, cache: 'no-cache', credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
|
||||
return await resp.json();
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function npRowFor() {
|
||||
var row = document.createElement('div'); row.className = 'np-row';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text'; input.className = 'np-row__input'; input.placeholder = 'email or role pattern';
|
||||
var del = document.createElement('button');
|
||||
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
|
||||
row.appendChild(input); row.appendChild(del);
|
||||
return row;
|
||||
}
|
||||
function npPermRow() {
|
||||
var row = document.createElement('div'); row.className = 'np-row';
|
||||
var pat = document.createElement('input');
|
||||
pat.type = 'text'; pat.className = 'np-row__input'; pat.placeholder = 'pattern (email or role)'; pat.dataset.role = 'pattern';
|
||||
var verbs = document.createElement('input');
|
||||
verbs.type = 'text'; verbs.className = 'np-row__verbs'; verbs.placeholder = 'verbs e.g. rwc'; verbs.dataset.role = 'verbs';
|
||||
var del = document.createElement('button');
|
||||
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
|
||||
row.appendChild(pat); row.appendChild(verbs); row.appendChild(del);
|
||||
return row;
|
||||
}
|
||||
function npCollectList(field) {
|
||||
var out = [];
|
||||
document.querySelectorAll('#npForm .np-list[data-field="' + field + '"] .np-row').forEach(function (r) {
|
||||
var v = r.querySelector('input').value.trim(); if (v) out.push(v);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function npCollectPerms() {
|
||||
var out = {};
|
||||
document.querySelectorAll('#npForm .np-list[data-field="acl.permissions"] .np-row').forEach(function (r) {
|
||||
var pat = r.querySelector('input[data-role="pattern"]').value.trim(); if (!pat) return;
|
||||
out[pat] = r.querySelector('input[data-role="verbs"]').value.trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
var npWired = false;
|
||||
function wireNewProjectForm() {
|
||||
if (npWired) return;
|
||||
var form = document.getElementById('npForm');
|
||||
if (!form) return;
|
||||
npWired = true;
|
||||
form.querySelectorAll('button.np-add').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var field = btn.dataset.target;
|
||||
var host = form.querySelector('.np-list[data-field="' + field + '"]');
|
||||
host.appendChild(field === 'acl.permissions' ? npPermRow() : npRowFor());
|
||||
});
|
||||
});
|
||||
form.addEventListener('click', function (e) {
|
||||
if (e.target && e.target.classList && e.target.classList.contains('np-del')) {
|
||||
e.target.closest('.np-row').remove();
|
||||
}
|
||||
});
|
||||
form.addEventListener('submit', submitNewProject);
|
||||
}
|
||||
function openNewProject() {
|
||||
wireNewProjectForm();
|
||||
var modal = document.getElementById('newProjectModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
var name = document.getElementById('npName');
|
||||
if (name) name.focus();
|
||||
}
|
||||
function closeNewProject() {
|
||||
var modal = document.getElementById('newProjectModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
function submitNewProject(ev) {
|
||||
ev.preventDefault();
|
||||
var nameErr = document.getElementById('npNameErr'); nameErr.textContent = '';
|
||||
var perms = npCollectPerms();
|
||||
var body = { parent: '/', name: document.getElementById('npName').value.trim() };
|
||||
var title = document.getElementById('npTitleInput').value.trim();
|
||||
if (title) body.title = title;
|
||||
if (Object.keys(perms).length) body.acl = { permissions: perms };
|
||||
var admins = npCollectList('admins'); if (admins.length) body.admins = admins;
|
||||
var dcs = npCollectList('document_controllers'); if (dcs.length) body.document_controllers = dcs;
|
||||
var team = npCollectList('project_team'); if (team.length) body.project_team = team;
|
||||
var guests = npCollectList('guests'); if (guests.length) body.guests = guests;
|
||||
var submitBtn = ev.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
fetch(apiBase() + '.profile/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; });
|
||||
}).then(function (res) {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
if (res.ok) {
|
||||
closeNewProject();
|
||||
document.getElementById('npForm').reset();
|
||||
form_clearLists();
|
||||
// Surface the newly-created project in the list.
|
||||
fetchProjects().then(render);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var p = JSON.parse(res.text);
|
||||
if (p && p.errors && p.errors.length) {
|
||||
nameErr.textContent = p.errors.map(function (e) { return e.field + ': ' + e.message; }).join('; ');
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* not a field-error envelope */ }
|
||||
nameErr.textContent = 'HTTP ' + res.status + ': ' + res.text;
|
||||
});
|
||||
}
|
||||
function form_clearLists() {
|
||||
document.querySelectorAll('#npForm .np-list').forEach(function (l) { l.textContent = ''; });
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (detectMode() === 'project') {
|
||||
await renderProjectMode();
|
||||
|
|
@ -856,6 +983,17 @@
|
|||
|
||||
render();
|
||||
|
||||
// Show "+ New project" only if the server says this caller may create
|
||||
// one (same gate the POST endpoint enforces, so the UI never dangles
|
||||
// an affordance the server would 404).
|
||||
fetchAccess().then(function (access) {
|
||||
if (access && access.can_create_project) {
|
||||
var btn = document.getElementById('newProjectBtn');
|
||||
if (btn) btn.classList.remove('hidden');
|
||||
wireNewProjectForm();
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up keyboard shortcuts in the action-bar input: Enter saves,
|
||||
// Escape cancels.
|
||||
var input = document.getElementById('groupNameInput');
|
||||
|
|
@ -891,6 +1029,9 @@
|
|||
saveGroup: saveGroup,
|
||||
openSelectedVisible: openSelectedVisible,
|
||||
dismissWarning: dismissWarning,
|
||||
// New-project dialog.
|
||||
openNewProject: openNewProject,
|
||||
closeNewProject: closeNewProject,
|
||||
// Project-mode entry points (also tested directly).
|
||||
detectMode: detectMode,
|
||||
renderProjectMode: renderProjectMode,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@
|
|||
<h2>Projects</h2>
|
||||
<span id="projectCount" class="landing-count"></span>
|
||||
</div>
|
||||
<div class="landing-header-actions">
|
||||
<!-- Shown only when the server reports can_create_project. -->
|
||||
<button id="newProjectBtn" class="btn btn-primary btn-sm hidden" onclick="LandingApp.openNewProject()">+ New project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="projectListContainer" class="project-list-container">
|
||||
|
|
@ -94,6 +98,53 @@
|
|||
</div>
|
||||
</div><!-- /pickerView -->
|
||||
|
||||
<!-- New-project dialog. Mirrors the profile page's create form; POSTs to
|
||||
/.profile/projects, which gates on the create verb at the root. -->
|
||||
<div id="newProjectModal" class="np-modal hidden" role="dialog" aria-modal="true" aria-labelledby="npHeading">
|
||||
<div class="np-modal__backdrop" onclick="LandingApp.closeNewProject()"></div>
|
||||
<div class="np-modal__dialog">
|
||||
<div class="np-modal__head">
|
||||
<h2 id="npHeading">Create new project</h2>
|
||||
<button type="button" class="np-modal__close" onclick="LandingApp.closeNewProject()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p class="np-help">Creates a top-level project folder. You're recorded as its creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.</p>
|
||||
<form id="npForm" autocomplete="off">
|
||||
<label class="np-field">Name
|
||||
<input type="text" id="npName" maxlength="64" placeholder="e.g. Site-3" required>
|
||||
<span class="np-err" id="npNameErr"></span>
|
||||
</label>
|
||||
<label class="np-field">Title (optional)
|
||||
<input type="text" id="npTitleInput" maxlength="200" placeholder="Human-readable project title">
|
||||
</label>
|
||||
|
||||
<h3 class="np-grouphdr">Admins <span class="np-sub">full control (you're already an admin)</span></h3>
|
||||
<div class="np-list" data-field="admins"></div>
|
||||
<button type="button" class="np-add" data-target="admins">+ Add admin</button>
|
||||
|
||||
<h3 class="np-grouphdr">Document controllers <span class="np-sub">manage filing & records — read / write / create / delete</span></h3>
|
||||
<div class="np-list" data-field="document_controllers"></div>
|
||||
<button type="button" class="np-add" data-target="document_controllers">+ Add document controller</button>
|
||||
|
||||
<h3 class="np-grouphdr">Project team <span class="np-sub">contribute documents — read / write / create</span></h3>
|
||||
<div class="np-list" data-field="project_team"></div>
|
||||
<button type="button" class="np-add" data-target="project_team">+ Add team member</button>
|
||||
|
||||
<h3 class="np-grouphdr">Guests <span class="np-sub">read-only access</span></h3>
|
||||
<div class="np-list" data-field="guests"></div>
|
||||
<button type="button" class="np-add" data-target="guests">+ Add guest</button>
|
||||
|
||||
<h3 class="np-grouphdr">Advanced — ACL permissions <span class="np-sub">pattern → verbs (r w c d a); empty verbs = explicit deny</span></h3>
|
||||
<div class="np-list" data-field="acl.permissions"></div>
|
||||
<button type="button" class="np-add" data-target="acl.permissions">+ Add permission</button>
|
||||
|
||||
<div class="np-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="LandingApp.closeNewProject()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project mode (/<project>). Stage cards + MDL section. Shown
|
||||
by landing.js when location.pathname is a single segment. -->
|
||||
<div id="projectView" class="hidden">
|
||||
|
|
|
|||
|
|
@ -295,11 +295,14 @@ test.describe('Landing project mode', () => {
|
|||
staging: document.getElementById('stageStaging').getAttribute('href'),
|
||||
reviewing: document.getElementById('stageReviewing').getAttribute('href'),
|
||||
}));
|
||||
// working/staging/reviewing are virtual aggregators served at <dir>/ —
|
||||
// the trailing slash is required so the server 302s to the canonical
|
||||
// form (see ec9c9c7). archive is a real dir linked without it.
|
||||
expect(stageHrefs).toEqual({
|
||||
archive: '/Project-1/archive',
|
||||
working: '/Project-1/working',
|
||||
staging: '/Project-1/staging',
|
||||
reviewing: '/Project-1/reviewing',
|
||||
working: '/Project-1/working/',
|
||||
staging: '/Project-1/staging/',
|
||||
reviewing: '/Project-1/reviewing/',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue