Offer New organization in the header menu, picker and empty state
Once organizations existed, Quick Setup was reachable only through
Settings > Organizations > type an ID > New organization. This tree
has no header organization switcher, so the owner of a Console with
organization setup now gets "New organization" in the header avatar
menu, under the Organization picker on Settings > Organization, and
next to the Settings link in the no-organization empty state. Each
opens #/settings/organizations?new=1, which focuses the ID field and
then drops the flag. The form itself is unchanged.
The new browser test covers the menu at 1400 and 390px, the picker
and the empty state. It fails on 6ca7f3b65 and passes here.
This commit is contained in:
1 parent
4b40c7f57b
commit
60f3b3d78f
3 files changed
+97
-3
No files matched your search
@@ -227,6 +227,8 @@
|
||||
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
// "New organization" everywhere it is offered: the Organizations form with the ID field focused (org-create.js reads ?new=1).
|
||||
const NEW_ORG = '#/settings/organizations?new=1';
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
|
||||
// or from the broker itself.
|
||||
@@ -579,7 +581,7 @@
|
||||
let html;
|
||||
if (organizationError) html = `<div class="banner err" role="alert"><p>${txt(organizationError)}</p>${retry}</div>`;
|
||||
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
|
||||
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings > Organizations</a>.');
|
||||
else if (noOrganizations && err?.code === 'org-unavailable') html = `<div class="empty"><b>No organizations yet.</b><p>Create one in <a href="#/settings/organizations">Settings > Organizations</a>.</p><p><a class="btn" href="${NEW_ORG}">New organization</a></p></div>`;
|
||||
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
|
||||
else if (!r) return; // not read yet: the skeleton stays
|
||||
else {
|
||||
@@ -708,7 +710,8 @@
|
||||
async function businessView(get) {
|
||||
const b = (await get.api('business')).business || {};
|
||||
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
|
||||
if (orgs) await configRead; // orgSetup decides the "New organization" link
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
|
||||
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
|
||||
const launch = b.launch || {};
|
||||
const authKnown = instances.some(i => i.authority);
|
||||
@@ -1657,6 +1660,8 @@
|
||||
canSend = !isTester && c?.send === true;
|
||||
chatSend = !isTester && c?.chatSend === true;
|
||||
orgSetup = !isTester && c?.orgSetup === true;
|
||||
// The header menu offers "New organization" to the owner of a Console with organizations; nobody else can create one.
|
||||
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
|
||||
ssoOn = c?.sso === true;
|
||||
canDecide = !isTester && c?.decide === true;
|
||||
if (isTester) {
|
||||
|
||||
@@ -620,9 +620,12 @@
|
||||
if (!alive) return;
|
||||
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
|
||||
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
|
||||
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
|
||||
// ?new=1 ("New organization" in the header menu, the Organization picker and the empty state): focus the ID field, then drop
|
||||
// the flag so the same link focuses it again next time. This runs after the page heading took the navigation focus.
|
||||
if (params.get('new') === '1' && !org) { $('org-new-id').focus(); setQuery(null, null); }
|
||||
await refreshList();
|
||||
// Resume: ?org=ID reopens the organization and its setup, read back from the server.
|
||||
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
|
||||
if (org && ID.test(org)) {
|
||||
await openExisting(org, { toSetup: setup });
|
||||
if (created && st.setup?.notifications?.receipt) showReceipt('Organization created', st.setup.notifications);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Walkthrough defect 3 (Jason, 7350): once organizations exist, Quick Setup was reachable only through
|
||||
// Settings > Organizations > type an ID > New organization. "New organization" is now offered in the header menu,
|
||||
// in the organization picker and in the no-organization empty state. Each opens that form with the ID field focused.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
// An owner Console with organization setup, holding the given organizations (the org-switch-browser fixture).
|
||||
async function consoleWith(t, ids) {
|
||||
const makeDoc = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} }, appearance: { theme: 'harbor', mode: 'dark' } });
|
||||
const docs = new Map(ids.map(id => [id, makeDoc(id)]));
|
||||
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
|
||||
const service = {
|
||||
orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
|
||||
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
|
||||
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
|
||||
notificationShow: missing, queueShow: missing, projectRepoShow: missing,
|
||||
};
|
||||
const registry = { listOrgs: async () => [...docs.keys()].map(id => ({ id, state: 'active' })) };
|
||||
const organizations = {
|
||||
list: async () => [...docs.keys()].map(id => ({ id, label: id, busHealthy: true })),
|
||||
resolve: async id => docs.has(id) ? { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id, projects: ['work'], appearance: docs.get(id).appearance } }), busStatus: async () => ({ live: true }) }, bus: {} } : null,
|
||||
};
|
||||
const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = (expression, label = expression) => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timed out: '+${JSON.stringify(label)}+'\\n'+document.body.innerText.slice(0,800))})()`);
|
||||
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).visibility !== 'hidden' && e.getClientRects().length > 0})()`);
|
||||
// The form is open, untouched, with the ID field focused, and the ?new=1 flag is gone so the link works again.
|
||||
const formFocused = async (prefix, where) => {
|
||||
await wait(`document.activeElement?.id === 'org-new-id'`, `${where}: ID field focused`);
|
||||
assert.equal(await b.evaluate('location.hash'), `${prefix}/settings/organizations`, `${where}: the flag is dropped`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#org-new-id').value`), '', `${where}: the form is not prefilled`);
|
||||
assert.equal(await shown('#org-new-form [data-write]'), true, `${where}: New organization is shown`);
|
||||
};
|
||||
return { base: `http://127.0.0.1:${server.address().port}/`, b, wait, shown, formFocused };
|
||||
}
|
||||
|
||||
for (const [width, height] of [[1400, 900], [390, 844]]) {
|
||||
test(`the header menu offers New organization once organizations exist, at ${width}px`, { timeout: 60000 }, async t => {
|
||||
const { base, b, wait, shown, formFocused } = await consoleWith(t, ['alpha']);
|
||||
await b.viewport(width, height);
|
||||
// From the dashboard, then again from the Organizations page itself.
|
||||
for (const route of ['#/orgs/alpha/', '#/orgs/alpha/settings/organizations']) {
|
||||
await b.navigate(`${base}?r=${encodeURIComponent(route)}${route}`);
|
||||
await wait(`document.querySelector('#avatar-menu a#org-new-link')`, `${route}: menu entry`);
|
||||
await b.evaluate(`document.querySelector('#avatar-button').click()`);
|
||||
assert.equal(await shown('#org-new-link'), true, `${route}: the entry is visible in the open menu`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').textContent`), 'New organization');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').getAttribute('role')`), 'menuitem');
|
||||
await b.evaluate(`document.querySelector('#org-new-link').click()`);
|
||||
await formFocused('#/orgs/alpha', route);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#avatar-menu').hidden`), true, `${route}: the menu closes`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
|
||||
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
|
||||
await b.viewport(1400, 900);
|
||||
await b.navigate(`${base}#/orgs/alpha/settings/business`);
|
||||
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
|
||||
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
|
||||
await b.evaluate(`${link}.click()`);
|
||||
await formFocused('#/orgs/alpha', 'picker');
|
||||
});
|
||||
|
||||
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
|
||||
const { base, b, wait, formFocused } = await consoleWith(t, []);
|
||||
await b.viewport(1400, 900);
|
||||
await b.navigate(`${base}#/`);
|
||||
const button = `document.querySelector('#held a.btn[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(button, 'empty state button');
|
||||
const held = await b.evaluate(`document.querySelector('#held').textContent`);
|
||||
assert.match(held, /No organizations yet/);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#held a[href="#/settings/organizations"]')`), true, 'the Settings link stays');
|
||||
assert.equal(await b.evaluate(`${button}.textContent`), 'New organization');
|
||||
await b.evaluate(`${button}.click()`);
|
||||
await formFocused('#', 'empty state');
|
||||
});
|
||||
Reference in new issue
Block a user