mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-30 17:16:18 +00:00
Redesign the Teams tab in the dashboard (#3901)
* Redesign the Teams tab in the dashboard Hero card with a lettered tile, role pill, handle and headcount; the directory setting becomes a settings row with the shared toggle switch; the add-account form gets labels, Enter to submit, and a copy button on the one-time password. The accounts and record tables drop the grid for hairlines, avatar tiles and status pills, and stack into labelled rows on phones so actions stay reachable. Loading, empty and error states get a skeleton, a create call to action, and a retry. Also fixes audit dates showing 1970 (the audit routes send unix seconds), the actions cell breaking the row border by being a flex table cell, and the "0 suspended" clause on a healthy team. * Use the dashboard's overlay dialog in the Teams tab, not UIAlert Adds UIDashboardDialog, a confirm / alert / prompt card built like the share and properties modals: mounted in the dashboard window, revealed after a frame, dismissed by Escape, the close button or a backdrop press-and-release, with a focus trap and focus restored on close. On phones it docks to the bottom as a sheet. The Teams tab's confirms, error alerts, seat-limit warning and rename/create prompts all go through it; UIAlert and UIPrompt are no longer imported there.
This commit is contained in:
@@ -17,17 +17,20 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIPrompt from '../UIPrompt.js';
|
||||
import teamActionButton from './teamActionIcons.js';
|
||||
import UIDashboardDialog from './UIDashboardDialog.js';
|
||||
import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
auditSlice,
|
||||
canDeleteAccount,
|
||||
auditReasonKey,
|
||||
auditSlice,
|
||||
avatarHue,
|
||||
billingSummaryKey,
|
||||
canDeleteAccount,
|
||||
initialOf,
|
||||
membersBillingSummary,
|
||||
memberPlanLabel,
|
||||
parseTimestamp,
|
||||
sortMembers,
|
||||
} from './teamsConsole.js';
|
||||
|
||||
@@ -43,17 +46,15 @@ const AUDIT_ROWS_PER_PAGE = 10;
|
||||
/** In flight, so `init` and the initial-route `onActivate` don't both load. */
|
||||
let loadPromise = null;
|
||||
|
||||
const modalOptions = ($el_window) => ({
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
});
|
||||
/** Every dialog here is the dashboard's overlay card, mounted in the window. */
|
||||
const dialog = ($el_window, opts) => UIDashboardDialog({ $container: $el_window, ...opts });
|
||||
|
||||
const dateText = (value) => {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? '' : parsed.toLocaleDateString();
|
||||
const dateText = (value) => parseTimestamp(value)?.toLocaleDateString() ?? '';
|
||||
|
||||
/** The record needs the hour: several entries share a day. */
|
||||
const dateTimeText = (value) => {
|
||||
const date = parseTimestamp(value);
|
||||
return date ? date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
|
||||
};
|
||||
|
||||
const teamName = (team) => team?.name || team?.handle || i18n('teams_untitled');
|
||||
@@ -63,18 +64,69 @@ const setTabVisible = ($el_window, visible) => {
|
||||
$el_window.find('.dashboard-sidebar-item[data-section="teams"]').toggle(visible);
|
||||
};
|
||||
|
||||
// Inline like the other tabs' card icons: one-place glyphs, not shared assets.
|
||||
const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
const ICONS = {
|
||||
team: SVG('<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
|
||||
directory: SVG('<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/><circle cx="11" cy="9.5" r="2"/><path d="M7.5 15a4 4 0 0 1 7 0"/>'),
|
||||
copy: SVG('<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
|
||||
check: SVG('<path d="M20 6L9 17l-5-5"/>'),
|
||||
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
|
||||
warning: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
||||
};
|
||||
|
||||
// -- Rendering ------------------------------------------------------------
|
||||
|
||||
/** A lettered tile in place of a picture: team accounts have none. */
|
||||
const renderAvatar = (name, extraClass = '') => {
|
||||
const hue = avatarHue(name);
|
||||
return `<span class="teams-avatar${extraClass}" style="--teams-avatar-hue: ${hue};" aria-hidden="true">${html_encode(initialOf(name))}</span>`;
|
||||
};
|
||||
|
||||
const renderTeamPicker = () => {
|
||||
if ( state.teams.length < 2 ) return '';
|
||||
let h = '<div class="teams-picker">';
|
||||
h += `<label for="teams-picker-select">${i18n('teams_team')}</label>`;
|
||||
h += '<select id="teams-picker-select" class="teams-picker-select">';
|
||||
let h = `<select class="teams-picker-select" aria-label="${html_encode(i18n('teams_team', [], false))}">`;
|
||||
for ( const team of state.teams ) {
|
||||
const selected = team.uid === state.selected?.uid ? ' selected' : '';
|
||||
h += `<option value="${html_encode(team.uid)}"${selected}>${html_encode(teamName(team))}</option>`;
|
||||
}
|
||||
h += '</select></div>';
|
||||
h += '</select>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Name, handle and headcount for the selected team, plus who you are in it. */
|
||||
const renderHero = () => {
|
||||
const team = state.selected;
|
||||
const count = state.members.length;
|
||||
let h = '<div class="dashboard-card teams-panel teams-hero">';
|
||||
h += '<div class="teams-hero-main">';
|
||||
h += renderAvatar(teamName(team), ' teams-avatar-lg');
|
||||
h += '<div class="teams-info">';
|
||||
h += '<div class="teams-info-title">';
|
||||
h += `<strong>${html_encode(teamName(team))}</strong>`;
|
||||
h += `<span class="teams-role">${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}</span>`;
|
||||
h += '</div>';
|
||||
const meta = [];
|
||||
// Nothing here sets a handle, so its absence is not worth a label.
|
||||
if ( team.handle ) meta.push(html_encode(`@${team.handle}`));
|
||||
meta.push(i18n(count === 1 ? 'teams_member_count_one' : 'teams_member_count', { count }));
|
||||
h += `<span class="teams-info-meta">${meta.join('<span class="teams-dot" aria-hidden="true">·</span>')}</span>`;
|
||||
h += '</div></div>';
|
||||
const picker = renderTeamPicker();
|
||||
const rename = team.isOwner ? `<button class="button teams-rename">${i18n('teams_rename')}</button>` : '';
|
||||
if ( picker || rename ) h += `<div class="teams-hero-actions">${picker}${rename}</div>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderCardHead = (title, hint, count = null) => {
|
||||
let h = '<div class="teams-card-head">';
|
||||
h += `<h3>${title}`;
|
||||
if ( count !== null ) h += `<span class="teams-count">${count}</span>`;
|
||||
h += '</h3>';
|
||||
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
@@ -84,56 +136,82 @@ const planCell = (member) => {
|
||||
return i18n(`teams_member_plan_${label.kind}`);
|
||||
};
|
||||
|
||||
const renderStatus = (member) => {
|
||||
const kind = member.disabled ? 'disabled' : 'active';
|
||||
return `<span class="teams-status teams-status-${kind}">${i18n(`teams_member_state_${kind}`)}</span>`;
|
||||
};
|
||||
|
||||
const renderIdentity = (username, subline) => {
|
||||
const you = username === window.user?.username;
|
||||
let h = '<div class="teams-identity">';
|
||||
h += renderAvatar(username);
|
||||
h += '<div class="teams-identity-text">';
|
||||
h += `<span class="teams-identity-name">${html_encode(username)}`;
|
||||
if ( you ) h += `<span class="teams-you">${i18n('share_you')}</span>`;
|
||||
h += '</span>';
|
||||
if ( subline ) h += `<span class="teams-identity-sub">${subline}</span>`;
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderMemberActions = (member) => {
|
||||
if ( ! member.orgOwned ) return '';
|
||||
const forMember = { 'data-username': member.username };
|
||||
let h = '<div class="teams-member-actions">';
|
||||
if ( window.team_billing_ui && state.plan?.status === 'ready' ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-plan-change',
|
||||
icon: 'plan',
|
||||
label: i18n('teams_plan_change'),
|
||||
attrs: { ...forMember, 'data-uuid': member.uuid ?? '' },
|
||||
});
|
||||
}
|
||||
h += teamActionButton({
|
||||
className: 'teams-reset',
|
||||
icon: 'credential',
|
||||
label: i18n('teams_reissue_credential'),
|
||||
attrs: forMember,
|
||||
});
|
||||
h += member.disabled
|
||||
? teamActionButton({
|
||||
className: 'teams-enable',
|
||||
icon: 'enable',
|
||||
label: i18n('teams_enable_account'),
|
||||
attrs: forMember,
|
||||
})
|
||||
: teamActionButton({
|
||||
className: 'teams-disable',
|
||||
icon: 'suspend',
|
||||
label: i18n('teams_disable_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
if ( canDeleteAccount(member) ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-delete-account',
|
||||
icon: 'remove',
|
||||
label: i18n('teams_delete_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
// `data-label` repeats the column heading so the phone layout, which drops
|
||||
// the header row, can print it beside each value.
|
||||
const labelled = (key) => `data-label="${html_encode(i18n(key, [], false))}"`;
|
||||
|
||||
const renderMemberRow = (member) => {
|
||||
const username = html_encode(member.username);
|
||||
let h = `<tr class="teams-member-row${member.disabled ? ' teams-member-disabled' : ''}" data-username="${username}">`;
|
||||
h += `<td>${username}</td>`;
|
||||
h += `<td>${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}</td>`;
|
||||
h += `<td>${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}</td>`;
|
||||
h += `<td>${html_encode(dateText(member.createdAt))}</td>`;
|
||||
h += `<td>${planCell(member)}</td>`;
|
||||
h += '<td class="teams-member-actions">';
|
||||
if ( member.orgOwned ) {
|
||||
const forMember = { 'data-username': member.username };
|
||||
if ( window.team_billing_ui && state.plan?.status === 'ready' ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-plan-change',
|
||||
icon: 'plan',
|
||||
label: i18n('teams_plan_change'),
|
||||
attrs: { ...forMember, 'data-uuid': member.uuid ?? '' },
|
||||
});
|
||||
}
|
||||
h += teamActionButton({
|
||||
className: 'teams-reset',
|
||||
icon: 'credential',
|
||||
label: i18n('teams_reissue_credential'),
|
||||
attrs: forMember,
|
||||
});
|
||||
h += member.disabled
|
||||
? teamActionButton({
|
||||
className: 'teams-enable',
|
||||
icon: 'enable',
|
||||
label: i18n('teams_enable_account'),
|
||||
attrs: forMember,
|
||||
})
|
||||
: teamActionButton({
|
||||
className: 'teams-disable',
|
||||
icon: 'suspend',
|
||||
label: i18n('teams_disable_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
if ( canDeleteAccount(member) ) {
|
||||
h += teamActionButton({
|
||||
className: 'teams-delete-account',
|
||||
icon: 'remove',
|
||||
label: i18n('teams_delete_account'),
|
||||
danger: true,
|
||||
attrs: forMember,
|
||||
});
|
||||
}
|
||||
}
|
||||
h += '</td></tr>';
|
||||
h += `<td class="teams-cell-identity">${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}</td>`;
|
||||
h += `<td ${labelled('teams_member_state')}>${renderStatus(member)}</td>`;
|
||||
h += `<td ${labelled('teams_member_since')}>${html_encode(dateText(member.createdAt))}</td>`;
|
||||
h += `<td ${labelled('teams_member_plan')}>${planCell(member)}</td>`;
|
||||
h += `<td class="teams-cell-actions">${renderMemberActions(member)}</td>`;
|
||||
h += '</tr>';
|
||||
return h;
|
||||
};
|
||||
|
||||
@@ -142,20 +220,21 @@ const renderMembers = () => {
|
||||
const summary = membersBillingSummary(annotated);
|
||||
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_accounts')}</h3>`;
|
||||
const billingKey = summary.billed === 1 ? 'teams_billing_summary_one' : 'teams_billing_summary';
|
||||
h += `<p class="teams-billing-note">${i18n(billingKey, { billed: summary.billed, disabled: summary.disabled })}</p>`;
|
||||
h += renderCardHead(
|
||||
i18n('teams_accounts'),
|
||||
i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }),
|
||||
annotated.length,
|
||||
);
|
||||
|
||||
if ( annotated.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_no_accounts')}</p>`;
|
||||
} else {
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table"><thead><tr>';
|
||||
h += `<th>${i18n('username')}</th>`;
|
||||
h += `<th>${i18n('teams_member_kind')}</th>`;
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table teams-members-table"><thead><tr>';
|
||||
h += `<th>${i18n('teams_member_account')}</th>`;
|
||||
h += `<th>${i18n('teams_member_state')}</th>`;
|
||||
h += `<th>${i18n('teams_member_since')}</th>`;
|
||||
h += `<th>${i18n('teams_member_plan')}</th>`;
|
||||
h += `<th>${i18n('teams_member_actions')}</th>`;
|
||||
h += `<th class="teams-cell-actions"><span class="sr-only">${i18n('teams_member_actions')}</span></th>`;
|
||||
h += '</tr></thead><tbody>';
|
||||
for ( const member of sortMembers(annotated) ) h += renderMemberRow(member);
|
||||
h += '</tbody></table></div>';
|
||||
@@ -166,48 +245,52 @@ const renderMembers = () => {
|
||||
|
||||
const renderAddAccount = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_add_account')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_add_account_hint')}</p>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_add_account_email_hint')}</p>`;
|
||||
h += '<div class="teams-form">';
|
||||
h += `<input class="teams-new-username" type="text" autocomplete="off" spellcheck="false" placeholder="${html_encode(i18n('username'))}">`;
|
||||
h += `<input class="teams-new-email" type="email" autocomplete="off" spellcheck="false" placeholder="${html_encode(i18n('teams_email_optional'))}">`;
|
||||
h += `<button class="button button-primary teams-add-btn">${i18n('teams_add_account')}</button>`;
|
||||
h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint'));
|
||||
h += '<form class="teams-form" novalidate>';
|
||||
h += '<div class="teams-field">';
|
||||
h += `<label for="teams-new-username">${i18n('username')}</label>`;
|
||||
h += '<input id="teams-new-username" class="teams-new-username" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" required>';
|
||||
h += '</div>';
|
||||
h += '<div class="teams-credential" style="display:none;"></div>';
|
||||
h += '<div class="teams-field">';
|
||||
h += `<label for="teams-new-email">${i18n('teams_email_optional')}</label>`;
|
||||
h += '<input id="teams-new-email" class="teams-new-email" type="email" autocomplete="off" spellcheck="false">';
|
||||
h += '</div>';
|
||||
h += `<button type="submit" class="button button-primary teams-add-btn">${i18n('teams_add_account')}</button>`;
|
||||
h += '</form>';
|
||||
h += `<p class="teams-field-help">${i18n('teams_add_account_email_hint')}</p>`;
|
||||
h += '<div class="teams-credential" role="status" aria-live="polite" style="display:none;"></div>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderAudit = () => {
|
||||
const renderAudit = ({ title, hint }) => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_audit')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_audit_hint')}</p>`;
|
||||
h += renderCardHead(title, hint, state.audit.length || null);
|
||||
|
||||
if ( state.audit.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_no_audit')}</p>`;
|
||||
} else {
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table"><thead><tr>';
|
||||
h += `<th>${i18n('teams_audit_when')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_action')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_account')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_actor')}</th>`;
|
||||
h += '</tr></thead><tbody>';
|
||||
const { items, page, pages, from, to, total } = auditSlice(
|
||||
state.audit,
|
||||
state.auditPage,
|
||||
AUDIT_ROWS_PER_PAGE,
|
||||
);
|
||||
h += '<div class="teams-table-wrapper"><table class="teams-table teams-audit-table"><thead><tr>';
|
||||
h += `<th>${i18n('teams_audit_action')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_account')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_actor')}</th>`;
|
||||
h += `<th>${i18n('teams_audit_when')}</th>`;
|
||||
h += '</tr></thead><tbody>';
|
||||
for ( const entry of items ) {
|
||||
const actionKey = auditActionKey(entry.action);
|
||||
const reasonKey = auditReasonKey(entry.reason);
|
||||
h += '<tr>';
|
||||
h += `<td>${html_encode(dateText(entry.createdAt))}</td>`;
|
||||
h += `<td>${actionKey ? i18n(actionKey) : html_encode(entry.action)}`;
|
||||
h += `<td class="teams-cell-action">${actionKey ? i18n(actionKey) : html_encode(entry.action)}`;
|
||||
if ( reasonKey ) h += ` <span class="teams-audit-reason">${i18n(reasonKey)}</span>`;
|
||||
h += '</td>';
|
||||
h += `<td>${html_encode(entry.username ?? '')}</td>`;
|
||||
h += `<td>${html_encode(entry.actorUsername ?? '')}</td>`;
|
||||
h += `<td ${labelled('teams_audit_account')}>${html_encode(entry.username ?? '')}</td>`;
|
||||
h += `<td ${labelled('teams_audit_actor')}>${html_encode(entry.actorUsername ?? '')}</td>`;
|
||||
h += `<td ${labelled('teams_audit_when')} class="teams-cell-when">${html_encode(dateTimeText(entry.createdAt))}</td>`;
|
||||
h += '</tr>';
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
@@ -227,17 +310,13 @@ const renderAudit = () => {
|
||||
/** Colleagues, by name. No state, no dates, no actions — none are theirs. */
|
||||
const renderRoster = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_roster')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_roster_hint')}</p>`;
|
||||
h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null);
|
||||
if ( state.members.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_roster_empty')}</p>`;
|
||||
} else {
|
||||
h += '<ul class="teams-roster">';
|
||||
for ( const member of sortMembers(state.members) ) {
|
||||
const you = member.username === window.user?.username;
|
||||
h += `<li class="teams-roster-name">${html_encode(member.username)}`;
|
||||
if ( you ) h += ` <span class="teams-roster-you">(${i18n('share_you')})</span>`;
|
||||
h += '</li>';
|
||||
h += `<li class="teams-roster-name">${renderIdentity(member.username)}</li>`;
|
||||
}
|
||||
h += '</ul>';
|
||||
}
|
||||
@@ -247,44 +326,37 @@ const renderRoster = () => {
|
||||
|
||||
/** What a member sees: who else is here, their own entries, nothing admin. */
|
||||
const renderMemberView = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_your_record')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_your_record_hint', { team: teamName(state.selected) })}</p>`;
|
||||
h += '</div>';
|
||||
return h + renderRoster() + renderAudit();
|
||||
return renderHero()
|
||||
+ renderRoster()
|
||||
+ renderAudit({
|
||||
title: i18n('teams_your_record'),
|
||||
hint: i18n('teams_your_record_hint', { team: teamName(state.selected) }),
|
||||
});
|
||||
};
|
||||
|
||||
const renderDirectory = () => {
|
||||
const on = state.selected?.directoryEnabled === true;
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h2>${i18n('teams_directory')}</h2>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_directory_hint')}</p>`;
|
||||
h += '<label class="teams-directory-toggle">';
|
||||
h += `<input type="checkbox" class="teams-directory-check"${on ? ' checked' : ''}>`;
|
||||
h += `<span>${i18n('teams_directory_label')}</span>`;
|
||||
let h = '<div class="dashboard-card dashboard-settings-card teams-directory">';
|
||||
h += '<div class="dashboard-settings-card-content">';
|
||||
h += `<div class="dashboard-settings-card-icon">${ICONS.directory}</div>`;
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('teams_directory_label')}</strong>`;
|
||||
h += `<span class="teams-directory-note">${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}</span>`;
|
||||
h += '</div></div>';
|
||||
h += '<label class="dashboard-switch teams-directory-toggle">';
|
||||
h += `<input type="checkbox" class="teams-directory-check"${on ? ' checked' : ''} aria-label="${html_encode(i18n('teams_directory_label', [], false))}">`;
|
||||
h += '<span class="dashboard-switch-slider"></span>';
|
||||
h += '</label>';
|
||||
h += `<p class="teams-directory-note">${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}</p>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderOwnerView = () => {
|
||||
let h = '<div class="dashboard-card teams-panel teams-card">';
|
||||
h += '<div class="teams-info">';
|
||||
h += `<strong>${html_encode(teamName(state.selected))}</strong>`;
|
||||
// Nothing here sets a handle, so its absence was a label for something the
|
||||
// owner could neither act on nor need.
|
||||
if ( state.selected.handle ) {
|
||||
h += `<span>${html_encode(`@${state.selected.handle}`)}</span>`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += `<button class="button teams-rename">${i18n('teams_rename')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
let h = renderHero();
|
||||
h += renderDirectory();
|
||||
h += renderAddAccount();
|
||||
h += renderMembers();
|
||||
h += renderAudit();
|
||||
h += renderAudit({ title: i18n('teams_audit'), hint: i18n('teams_audit_hint') });
|
||||
|
||||
h += '<div class="dashboard-danger-zone">';
|
||||
h += `<h3>${i18n('teams_danger_zone')}</h3>`;
|
||||
@@ -298,21 +370,49 @@ const renderOwnerView = () => {
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Placeholder cards the size of the ones about to arrive, so nothing jumps. */
|
||||
const renderSkeleton = () => {
|
||||
let h = `<div class="teams-skeleton" role="status" aria-busy="true"><span class="sr-only">${i18n('teams_loading')}</span>`;
|
||||
h += '<div class="dashboard-card teams-panel teams-skeleton-card teams-skeleton-hero"><i class="teams-skeleton-tile"></i><span><i style="width: 40%"></i><i style="width: 25%"></i></span></div>';
|
||||
for ( let i = 0; i < 2; i++ ) {
|
||||
h += '<div class="dashboard-card teams-panel teams-skeleton-card"><span><i style="width: 30%"></i><i style="width: 70%"></i><i style="width: 55%"></i></span></div>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderState = ({ icon, title, hint, action, tone = '' }) => {
|
||||
let h = `<div class="dashboard-card teams-panel teams-state${tone ? ` teams-state-${tone}` : ''}">`;
|
||||
h += `<div class="teams-state-art">${icon}</div>`;
|
||||
h += `<h3>${title}</h3>`;
|
||||
if ( hint ) h += `<p class="teams-panel-hint">${hint}</p>`;
|
||||
if ( action ) h += action;
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
// Nothing at all where teams are off: an empty panel is the honest
|
||||
// rendering of a feature this deployment does not have.
|
||||
if ( state.status === 'unavailable' ) return '';
|
||||
if ( state.status === 'loading' ) return `<p class="teams-empty">${i18n('teams_loading')}</p>`;
|
||||
if ( state.status === 'error' ) return `<p class="teams-empty">${i18n('teams_load_failed')}</p>`;
|
||||
if ( state.teams.length === 0 ) {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_create_team')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_create_team_hint')}</p>`;
|
||||
h += `<button class="button button-primary teams-create">${i18n('teams_create_team')}</button>`;
|
||||
h += '</div>';
|
||||
return h;
|
||||
if ( state.status === 'loading' ) return renderSkeleton();
|
||||
if ( state.status === 'error' ) {
|
||||
return renderState({
|
||||
icon: ICONS.warning,
|
||||
tone: 'warning',
|
||||
title: i18n('teams_load_failed'),
|
||||
action: `<button class="button teams-retry">${i18n('retry')}</button>`,
|
||||
});
|
||||
}
|
||||
return renderTeamPicker() + (state.selected?.isOwner ? renderOwnerView() : renderMemberView());
|
||||
if ( state.teams.length === 0 ) {
|
||||
return renderState({
|
||||
icon: ICONS.team,
|
||||
title: i18n('teams_create_team'),
|
||||
hint: i18n('teams_create_team_hint'),
|
||||
action: `<button class="button button-primary teams-create">${i18n('teams_create_team')}</button>`,
|
||||
});
|
||||
}
|
||||
return state.selected?.isOwner ? renderOwnerView() : renderMemberView();
|
||||
};
|
||||
|
||||
const paint = ($el_window) => {
|
||||
@@ -418,38 +518,39 @@ const showSeatLimit = async ($el_window, e) => {
|
||||
const canUpgrade = typeof window.UIUpgradeAccount === 'function';
|
||||
let message = `<p>${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}</p>`;
|
||||
message += `<p>${i18n('teams_seat_limit_upgrade')}</p>`;
|
||||
const answer = await UIAlert({
|
||||
type: 'warning',
|
||||
const answer = await dialog($el_window, {
|
||||
title: i18n('teams_add_account', [], false),
|
||||
tone: 'warning',
|
||||
message,
|
||||
buttons: canUpgrade
|
||||
? [
|
||||
{ label: i18n('teams_see_plans'), value: 'upgrade', type: 'primary' },
|
||||
{ label: i18n('cancel'), value: 'no' },
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' },
|
||||
]
|
||||
: [{ label: i18n('ok'), value: 'no' }],
|
||||
...modalOptions($el_window),
|
||||
: [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }],
|
||||
});
|
||||
if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window();
|
||||
};
|
||||
|
||||
const showError = ($el_window, e) => {
|
||||
if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e);
|
||||
return UIAlert({
|
||||
type: 'error',
|
||||
message: e?.message ?? i18n('error_unknown_cause'),
|
||||
...modalOptions($el_window),
|
||||
return dialog($el_window, {
|
||||
title: i18n('something_went_wrong', [], false),
|
||||
tone: 'error',
|
||||
message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)),
|
||||
});
|
||||
};
|
||||
|
||||
/** `label` names the action and titles the dialog; `kind` colours its button. */
|
||||
const confirm = async ($el_window, message, label, kind = 'danger') => {
|
||||
const answer = await UIAlert({
|
||||
type: 'confirm',
|
||||
const answer = await dialog($el_window, {
|
||||
title: label,
|
||||
tone: kind === 'danger' ? 'danger' : undefined,
|
||||
message,
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: 'no' },
|
||||
{ label, value: 'yes', type: kind },
|
||||
{ label: i18n('cancel'), value: 'no' },
|
||||
],
|
||||
...modalOptions($el_window),
|
||||
});
|
||||
return answer === 'yes';
|
||||
};
|
||||
@@ -457,10 +558,33 @@ const confirm = async ($el_window, message, label, kind = 'danger') => {
|
||||
/** Shown once and never retrievable, so it stays on screen until dismissed. */
|
||||
const showCredential = ($el_window, username, temporaryPassword) => {
|
||||
const $box = $el_window.find(`${SECTION} .teams-credential`);
|
||||
let h = `<strong>${i18n('teams_credential_heading', { username })}</strong>`;
|
||||
h += `<code class="teams-credential-value">${html_encode(temporaryPassword)}</code>`;
|
||||
h += `<span>${i18n('teams_credential_once')}</span>`;
|
||||
let h = '<div class="teams-credential-head">';
|
||||
h += `<strong>${i18n('teams_credential_heading', { username })}</strong>`;
|
||||
h += `<button type="button" class="teams-credential-dismiss" aria-label="${html_encode(i18n('close', [], false))}">${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
h += '<div class="teams-credential-row">';
|
||||
h += `<code class="teams-credential-value" tabindex="-1">${html_encode(temporaryPassword)}</code>`;
|
||||
h += `<button type="button" class="button button-small teams-credential-copy" data-value="${html_encode(temporaryPassword)}">${ICONS.copy}<span>${i18n('copy')}</span></button>`;
|
||||
h += '</div>';
|
||||
h += `<span class="teams-credential-note">${i18n('teams_credential_once')}</span>`;
|
||||
$box.html(h).show();
|
||||
$box.find('.teams-credential-value').trigger('focus');
|
||||
};
|
||||
|
||||
/** Confirms on the button itself; a toast would appear away from the value. */
|
||||
const copyCredential = async ($button) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($button.attr('data-value') ?? '');
|
||||
} catch {
|
||||
// Selecting the value is the fallback: it is one keystroke from copied.
|
||||
const node = $button.closest('.teams-credential').find('.teams-credential-value')[0];
|
||||
if ( node ) window.getSelection()?.selectAllChildren(node);
|
||||
return;
|
||||
}
|
||||
$button.addClass('teams-copied').html(`${ICONS.check}<span>${i18n('teams_credential_copied')}</span>`);
|
||||
setTimeout(() => {
|
||||
$button.removeClass('teams-copied').html(`${ICONS.copy}<span>${i18n('copy')}</span>`);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const addAccount = async ($el_window) => {
|
||||
@@ -470,7 +594,7 @@ const addAccount = async ($el_window) => {
|
||||
const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim();
|
||||
|
||||
const $button = $el_window.find(`${SECTION} .teams-add-btn`);
|
||||
$button.prop('disabled', true);
|
||||
$button.prop('disabled', true).addClass('teams-busy');
|
||||
try {
|
||||
const created = await puter.teams.createMember(state.selected.uid, {
|
||||
username,
|
||||
@@ -481,7 +605,7 @@ const addAccount = async ($el_window) => {
|
||||
} catch (e) {
|
||||
await showError($el_window, e);
|
||||
} finally {
|
||||
$el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false);
|
||||
$el_window.find(`${SECTION} .teams-add-btn`).prop('disabled', false).removeClass('teams-busy');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -489,7 +613,7 @@ const reissueCredential = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_reissue', { username })}</p>`,
|
||||
i18n('teams_reissue_credential'),
|
||||
i18n('teams_reissue_credential', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
@@ -506,7 +630,7 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_disable', { username })}</p>`,
|
||||
i18n('teams_disable_account'),
|
||||
i18n('teams_disable_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
}
|
||||
@@ -521,11 +645,14 @@ const setMemberEnabled = async ($el_window, username, enabled) => {
|
||||
};
|
||||
|
||||
const renameTeam = async ($el_window) => {
|
||||
const name = await UIPrompt({
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_rename', [], false),
|
||||
message: i18n('teams_rename_prompt'),
|
||||
placeholder: i18n('teams_team_name'),
|
||||
defaultValue: state.selected.name ?? '',
|
||||
...modalOptions($el_window),
|
||||
input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
@@ -540,7 +667,7 @@ const deleteMemberAccount = async ($el_window, username) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_confirm_delete_account', { username }),
|
||||
i18n('teams_delete_account'),
|
||||
i18n('teams_delete_account', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
@@ -558,7 +685,7 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
|
||||
const ok = await confirm(
|
||||
$el_window,
|
||||
i18n('teams_directory_confirm'),
|
||||
i18n('teams_directory_confirm_action'),
|
||||
i18n('teams_directory_confirm_action', [], false),
|
||||
// Reversible, and the wording says so — red would overstate it.
|
||||
'primary',
|
||||
);
|
||||
@@ -575,10 +702,14 @@ const setDirectoryEnabled = async ($el_window, enabled) => {
|
||||
};
|
||||
|
||||
const createTeam = async ($el_window) => {
|
||||
const name = await UIPrompt({
|
||||
const name = await dialog($el_window, {
|
||||
title: i18n('teams_create_team', [], false),
|
||||
message: i18n('teams_create_team_prompt'),
|
||||
placeholder: i18n('teams_team_name'),
|
||||
...modalOptions($el_window),
|
||||
input: { placeholder: i18n('teams_team_name', [], false) },
|
||||
buttons: [
|
||||
{ label: i18n('cancel', [], false), value: false },
|
||||
{ label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' },
|
||||
],
|
||||
});
|
||||
if ( name === false || name.trim() === '' ) return;
|
||||
try {
|
||||
@@ -595,7 +726,7 @@ const deleteTeam = async ($el_window) => {
|
||||
$el_window,
|
||||
`<p>${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}</p>`
|
||||
+ `<p>${i18n('teams_confirm_delete_billing')}</p>`,
|
||||
i18n('teams_delete_team'),
|
||||
i18n('teams_delete_team', [], false),
|
||||
);
|
||||
if ( ! ok ) return;
|
||||
try {
|
||||
@@ -639,7 +770,22 @@ const TabTeams = {
|
||||
state.auditPage += 1;
|
||||
paint($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-add-btn`, () => addAccount($el_window));
|
||||
// Submit rather than click, so Enter in either field adds the account too.
|
||||
$el_window.on('submit', `${SECTION} .teams-form`, (e) => {
|
||||
e.preventDefault();
|
||||
addAccount($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-credential-copy`, function () {
|
||||
copyCredential($(this));
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-credential-dismiss`, () => {
|
||||
$el_window.find(`${SECTION} .teams-credential`).hide().empty();
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-retry`, () => {
|
||||
state.status = 'loading';
|
||||
paint($el_window);
|
||||
refresh($el_window);
|
||||
});
|
||||
$el_window.on('click', `${SECTION} .teams-create`, () => createTeam($el_window));
|
||||
$el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window));
|
||||
$el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window));
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, replacements) => {
|
||||
if ( replacements && typeof replacements === 'object' && ! Array.isArray(replacements) ) {
|
||||
return `${key}(${Object.entries(replacements).map(([k, v]) => `${k}=${v}`).join(',')})`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
|
||||
const { default: TabTeams } = await import('./TabTeams.js');
|
||||
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const team = { uid: 't1', name: 'Acme Design', handle: 'acme', isOwner: true, directoryEnabled: false };
|
||||
const members = [
|
||||
{ username: 'dana', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-dana' },
|
||||
{ username: 'boss', orgOwned: false, createdAt: '2026-09-01T00:00:00Z' },
|
||||
{ username: 'elliot', orgOwned: true, createdAt: '2026-09-17T00:00:00Z', uuid: 'u-elliot' },
|
||||
];
|
||||
// Newest first, as the API sends it; unix seconds, as the audit routes send it.
|
||||
const audit = [
|
||||
{ action: 'disable', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789672096 },
|
||||
{ action: 'provision', username: 'elliot', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
|
||||
{ action: 'provision', username: 'dana', reason: null, actorUsername: 'boss', createdAt: 1789600000 },
|
||||
];
|
||||
|
||||
const mount = async ({ teams = [team], plan = null } = {}) => {
|
||||
window.teams_ui = true;
|
||||
window.team_billing_ui = false;
|
||||
window.api_origin = 'http://api.test';
|
||||
window.user = { username: 'boss' };
|
||||
globalThis.puter = {
|
||||
authToken: 'tok',
|
||||
teams: {
|
||||
list: vi.fn(async () => teams),
|
||||
listAudit: vi.fn(async () => audit),
|
||||
listOwnAudit: vi.fn(async () => audit.filter(e => e.username === 'boss')),
|
||||
listMembers: vi.fn(async () => members),
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: false }));
|
||||
if ( plan ) globalThis.fetch = plan;
|
||||
|
||||
document.body.innerHTML = `<div class="dashboard" data-element_uuid="w1">
|
||||
<a class="dashboard-sidebar-item" data-section="teams"></a>
|
||||
<div class="dashboard-section dashboard-section-teams">${TabTeams.html()}</div>
|
||||
</div>`;
|
||||
const $el_window = $('.dashboard');
|
||||
TabTeams.init($el_window);
|
||||
await flush();
|
||||
await flush();
|
||||
return $el_window;
|
||||
};
|
||||
|
||||
describe('the owner view', () => {
|
||||
let $w;
|
||||
beforeEach(async () => { $w = await mount(); });
|
||||
|
||||
it('names the team, its role and headcount in the hero', () => {
|
||||
const hero = $w.find('.teams-hero');
|
||||
expect(hero.find('strong').text()).toBe('Acme Design');
|
||||
expect(hero.find('.teams-role').text()).toBe('teams_role_owner');
|
||||
expect(hero.find('.teams-info-meta').text()).toContain('@acme');
|
||||
expect(hero.find('.teams-info-meta').text()).toContain('teams_member_count(count=3)');
|
||||
expect(hero.find('.teams-rename')).toHaveLength(1);
|
||||
// One team: no picker to choose it with.
|
||||
expect(hero.find('.teams-picker-select')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the directory as a switch that still carries the wired class', () => {
|
||||
const input = $w.find('.dashboard-switch input.teams-directory-check');
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input.is(':checked')).toBe(false);
|
||||
expect($w.find('.teams-directory-note').text()).toBe('teams_directory_off_note');
|
||||
});
|
||||
|
||||
it('lists accounts with a status pill and folds the audit state in', () => {
|
||||
const rows = $w.find('.teams-members-table tbody tr');
|
||||
expect(rows).toHaveLength(3);
|
||||
// Suspended last, provisioned before joined.
|
||||
expect(rows.map((_, r) => $(r).attr('data-username')).get()).toEqual(['dana', 'boss', 'elliot']);
|
||||
expect(rows.eq(2).hasClass('teams-member-disabled')).toBe(true);
|
||||
expect(rows.eq(2).find('.teams-status').hasClass('teams-status-disabled')).toBe(true);
|
||||
expect(rows.eq(0).find('.teams-status').hasClass('teams-status-active')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the signed-in account and gives it no actions', () => {
|
||||
const you = $w.find('.teams-member-row[data-username="boss"]');
|
||||
expect(you.find('.teams-you')).toHaveLength(1);
|
||||
expect(you.find('.teams-member-actions')).toHaveLength(0);
|
||||
expect($w.find('.teams-member-row[data-username="dana"] .teams-you')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('offers restore and delete only for a suspended account', () => {
|
||||
const dana = $w.find('.teams-member-row[data-username="dana"]');
|
||||
const elliot = $w.find('.teams-member-row[data-username="elliot"]');
|
||||
expect(dana.find('.teams-disable')).toHaveLength(1);
|
||||
expect(dana.find('.teams-delete-account')).toHaveLength(0);
|
||||
expect(elliot.find('.teams-enable')).toHaveLength(1);
|
||||
expect(elliot.find('.teams-delete-account')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('labels every data cell for the phone layout, which drops the header row', () => {
|
||||
const cells = $w.find('.teams-members-table tbody tr').first().find('td[data-label]');
|
||||
expect(cells.map((_, c) => $(c).attr('data-label')).get()).toEqual([
|
||||
'teams_member_state', 'teams_member_since', 'teams_member_plan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the actions in a block inside the cell, not on the cell itself', () => {
|
||||
const cell = $w.find('.teams-member-row[data-username="dana"] td.teams-cell-actions');
|
||||
expect(cell.children('.teams-member-actions')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prints audit seconds as a real date, not 1970', () => {
|
||||
const when = $w.find('.teams-audit-table tbody tr').first().find('.teams-cell-when').text();
|
||||
expect(when).toContain('2026');
|
||||
expect(when).not.toContain('1970');
|
||||
});
|
||||
|
||||
it('skips the suspended clause of the billing note when there is nothing suspended', async () => {
|
||||
expect($w.find('.teams-panel-hint').filter((_, el) => $(el).text().startsWith('teams_billing_summary')).text())
|
||||
.toBe('teams_billing_summary_one(billed=1,disabled=1)');
|
||||
});
|
||||
|
||||
it('adds an account on form submit, so Enter works, and shows the credential with a copy button', async () => {
|
||||
globalThis.puter.teams.createMember = vi.fn(async () => ({ username: 'gil', temporaryPassword: 'p4ss' }));
|
||||
$w.find('.teams-new-username').val('gil');
|
||||
$w.find('.teams-form').trigger('submit');
|
||||
await flush();
|
||||
await flush();
|
||||
await flush();
|
||||
expect(globalThis.puter.teams.createMember).toHaveBeenCalledWith('t1', { username: 'gil' });
|
||||
const box = $w.find('.teams-credential');
|
||||
expect(box.is(':visible') || box.css('display') !== 'none').toBe(true);
|
||||
expect(box.find('.teams-credential-value').text()).toBe('p4ss');
|
||||
expect(box.find('.teams-credential-copy').attr('data-value')).toBe('p4ss');
|
||||
box.find('.teams-credential-dismiss').trigger('click');
|
||||
expect(box.children()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the member view', () => {
|
||||
it('shows the roster and own record, and nothing an administrator would use', async () => {
|
||||
const $w = await mount({ teams: [{ ...team, isOwner: false }] });
|
||||
expect($w.find('.teams-role').text()).toBe('teams_role_member');
|
||||
expect($w.find('.teams-rename')).toHaveLength(0);
|
||||
expect($w.find('.teams-roster .teams-identity')).toHaveLength(3);
|
||||
expect($w.find('.teams-directory-check')).toHaveLength(0);
|
||||
expect($w.find('.teams-form')).toHaveLength(0);
|
||||
expect($w.find('.dashboard-danger-zone')).toHaveLength(0);
|
||||
expect($w.find('.teams-panel h3').first().text()).toContain('teams_roster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('states', () => {
|
||||
it('shows a picker when there is more than one team', async () => {
|
||||
const $w = await mount({ teams: [team, { ...team, uid: 't2', name: 'Other' }] });
|
||||
expect($w.find('.teams-hero .teams-picker-select option')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('offers to create a team when there is none', async () => {
|
||||
const $w = await mount({ teams: [] });
|
||||
expect($w.find('.teams-state .teams-create')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('offers a retry when the load fails', async () => {
|
||||
window.teams_ui = true;
|
||||
const $w = await mount();
|
||||
globalThis.puter.teams.list = vi.fn(async () => { throw new Error('boom'); });
|
||||
await TabTeams.onActivate($w);
|
||||
await flush();
|
||||
expect($w.find('.teams-state .teams-retry')).toHaveLength(1);
|
||||
globalThis.puter.teams.list = vi.fn(async () => [team]);
|
||||
$w.find('.teams-retry').trigger('click');
|
||||
expect($w.find('.teams-skeleton')).toHaveLength(1);
|
||||
await flush();
|
||||
await flush();
|
||||
expect($w.find('.teams-hero')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The tab pulls in the dialog primitives, which pull in the whole window stack.
|
||||
// Only the tab-object contract is under test here.
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIPrompt.js', () => ({ default: vi.fn() }));
|
||||
// The tab's dialogs mount into the window; only the tab-object contract is under test here.
|
||||
vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The dashboard's own confirm / alert / prompt: an overlay card like the
|
||||
// share and properties modals, not a UIWindow. Resolves with the pressed
|
||||
// button's value (or the input's text), and `false` when dismissed.
|
||||
|
||||
const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
|
||||
danger: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
||||
warning: SVG('<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
|
||||
error: SVG('<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>'),
|
||||
};
|
||||
|
||||
const TONES = new Set(['danger', 'warning', 'error']);
|
||||
const BUTTON_TYPES = new Set(['primary', 'danger', 'quiet']);
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.title] Plain text.
|
||||
* @param {string} opts.message HTML the caller has already encoded.
|
||||
* @param {'danger'|'warning'|'error'} [opts.tone] Adds an icon; the primary
|
||||
* button of a `danger` dialog is red.
|
||||
* @param {Array<{ label: string, value: unknown, type?: 'primary'|'danger'|'quiet' }>} [opts.buttons]
|
||||
* Rendered in order, right-aligned. Defaults to a single OK.
|
||||
* @param {{ label?: string, placeholder?: string, value?: string }} [opts.input]
|
||||
* Turns the dialog into a prompt: Enter presses the primary button and the
|
||||
* result is the trimmed text rather than the button's value.
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export default function UIDashboardDialog ({ title, message, tone, buttons, input, $container, returnFocusTo }) {
|
||||
const { html_encode } = window;
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const id = window.uuidv4?.() ?? String(Date.now());
|
||||
const toneClass = TONES.has(tone) ? ` dashboard-dialog-${tone}` : '';
|
||||
const actions = buttons?.length
|
||||
? buttons
|
||||
: [{ label: i18n('ok'), value: true, type: 'primary' }];
|
||||
const primary = actions.find(b => b.type === 'primary' || b.type === 'danger') ?? actions[actions.length - 1];
|
||||
|
||||
let h = '<div class="dashboard-dialog-overlay">';
|
||||
h += `<div class="dashboard-dialog${toneClass}" role="${input ? 'dialog' : 'alertdialog'}" aria-modal="true" tabindex="-1"`;
|
||||
h += ` aria-labelledby="dashboard-dialog-title-${id}" aria-describedby="dashboard-dialog-message-${id}">`;
|
||||
if ( title ) {
|
||||
h += '<div class="dashboard-dialog-header">';
|
||||
h += `<span class="dashboard-dialog-title" id="dashboard-dialog-title-${id}">${html_encode(title)}</span>`;
|
||||
h += `<button type="button" class="dashboard-dialog-close" aria-label="${html_encode(i18n('close', [], false))}">${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '<div class="dashboard-dialog-body">';
|
||||
if ( toneClass ) h += `<div class="dashboard-dialog-icon">${ICONS[tone]}</div>`;
|
||||
h += '<div class="dashboard-dialog-content">';
|
||||
h += `<div class="dashboard-dialog-message" id="dashboard-dialog-message-${id}">${message ?? ''}</div>`;
|
||||
if ( input ) {
|
||||
h += '<label class="dashboard-dialog-field">';
|
||||
if ( input.label ) h += `<span>${html_encode(input.label)}</span>`;
|
||||
h += `<input type="text" class="dashboard-dialog-input" autocomplete="off" spellcheck="false"`;
|
||||
h += ` placeholder="${html_encode(input.placeholder ?? '')}" value="${html_encode(input.value ?? '')}">`;
|
||||
h += '</label>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
h += '<div class="dashboard-dialog-footer">';
|
||||
actions.forEach((button, index) => {
|
||||
const type = BUTTON_TYPES.has(button.type) ? button.type : 'quiet';
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-${type}" data-index="${index}">${html_encode(button.label)}</button>`;
|
||||
});
|
||||
h += '</div></div></div>';
|
||||
|
||||
const $overlay = $(h);
|
||||
$root.append($overlay);
|
||||
requestAnimationFrame(() => $overlay.addClass('dashboard-dialog-show'));
|
||||
|
||||
const el_previous_focus = document.activeElement;
|
||||
const $input = $overlay.find('.dashboard-dialog-input');
|
||||
// A prompt wants the text; anything else, the safest button.
|
||||
const $first = $input.length
|
||||
? $input
|
||||
: $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first();
|
||||
($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true });
|
||||
if ( $input.length ) $input.get(0).select();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let closed = false;
|
||||
const close = (value) => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
$overlay.removeClass('dashboard-dialog-show');
|
||||
$(document).off(`keydown.dashboard-dialog-${id}`);
|
||||
setTimeout(() => $overlay.remove(), 200);
|
||||
const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el));
|
||||
try {
|
||||
el_focus_target?.focus({ preventScroll: true });
|
||||
} catch { /* focus restoration is best-effort */ }
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const submit = (button) => {
|
||||
if ( ! button ) return close(false);
|
||||
if ( $input.length && button === primary ) return close(String($input.val() ?? '').trim());
|
||||
close(button.value);
|
||||
};
|
||||
|
||||
$overlay.on('click', '.dashboard-dialog-btn', function () {
|
||||
submit(actions[Number($(this).attr('data-index'))]);
|
||||
});
|
||||
$overlay.on('click', '.dashboard-dialog-close', () => close(false));
|
||||
|
||||
// Backdrop close goes by where the press started, so a drag that
|
||||
// starts in the card and ends outside does not dismiss it.
|
||||
let backdrop_pressed = false;
|
||||
$overlay.on('mousedown', function (e) {
|
||||
backdrop_pressed = e.target === $overlay[0];
|
||||
// A press inside the overlay must not let initgui's global mousedown
|
||||
// hand focus (and the z-index) back to the window underneath it.
|
||||
window.mouseover_window = undefined;
|
||||
});
|
||||
$overlay.on('click', function (e) {
|
||||
if ( e.target === $overlay[0] && backdrop_pressed ) close(false);
|
||||
});
|
||||
$(document).on(`keydown.dashboard-dialog-${id}`, function (e) {
|
||||
if ( e.key === 'Escape' ) close(false);
|
||||
});
|
||||
$input.on('keydown', function (e) {
|
||||
if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
submit(primary);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the dialog while it's up.
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay
|
||||
.find('button, input, [tabindex]:not([tabindex="-1"])')
|
||||
.filter(':visible:not(:disabled)');
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables.get(0);
|
||||
const last = focusables.get(focusables.length - 1);
|
||||
if ( focusables.index(document.activeElement) === -1 ) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
|
||||
const { default: UIDashboardDialog } = await import('./UIDashboardDialog.js');
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const overlay = () => $('.dashboard-dialog-overlay');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('UIDashboardDialog', () => {
|
||||
it('mounts an overlay card, not a window, and reveals it after a frame', async () => {
|
||||
UIDashboardDialog({ title: 'T', message: 'hello' });
|
||||
expect(overlay()).toHaveLength(1);
|
||||
expect($('.window')).toHaveLength(0);
|
||||
expect(overlay().hasClass('dashboard-dialog-show')).toBe(false);
|
||||
await tick();
|
||||
expect(overlay().hasClass('dashboard-dialog-show')).toBe(true);
|
||||
expect($('.dashboard-dialog-title').text()).toBe('T');
|
||||
expect($('.dashboard-dialog-message').html()).toBe('hello');
|
||||
});
|
||||
|
||||
it('resolves with the pressed button value', async () => {
|
||||
const p = UIDashboardDialog({
|
||||
message: 'm',
|
||||
buttons: [{ label: 'No', value: 'no' }, { label: 'Yes', value: 'yes', type: 'danger' }],
|
||||
});
|
||||
expect($('.dashboard-dialog-btn-danger').text()).toBe('Yes');
|
||||
$('.dashboard-dialog-btn-danger').trigger('click');
|
||||
await expect(p).resolves.toBe('yes');
|
||||
});
|
||||
|
||||
it('resolves false on Escape, on the close button, and on a backdrop press-and-release', async () => {
|
||||
const a = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
$(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
await expect(a).resolves.toBe(false);
|
||||
|
||||
const b = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
$('.dashboard-dialog-close').last().trigger('click');
|
||||
await expect(b).resolves.toBe(false);
|
||||
|
||||
const c = UIDashboardDialog({ title: 'T', message: 'm' });
|
||||
const el = overlay().last().get(0);
|
||||
$(el).trigger($.Event('mousedown', { target: el }));
|
||||
$(el).trigger($.Event('click', { target: el }));
|
||||
await expect(c).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not dismiss when a press starts inside the card and ends on the backdrop', async () => {
|
||||
const resolved = vi.fn();
|
||||
UIDashboardDialog({ title: 'T', message: 'm' }).then(resolved);
|
||||
const el = overlay().get(0);
|
||||
const card = $('.dashboard-dialog').get(0);
|
||||
$(card).trigger($.Event('mousedown', { target: card }));
|
||||
$(el).trigger($.Event('click', { target: el }));
|
||||
await tick();
|
||||
expect(resolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('as a prompt, returns the trimmed text from the primary button or Enter', async () => {
|
||||
const a = UIDashboardDialog({
|
||||
message: 'm',
|
||||
input: { value: 'old' },
|
||||
buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }],
|
||||
});
|
||||
expect($('.dashboard-dialog-input').val()).toBe('old');
|
||||
$('.dashboard-dialog-input').val(' new name ');
|
||||
$('.dashboard-dialog-btn-primary').trigger('click');
|
||||
await expect(a).resolves.toBe('new name');
|
||||
|
||||
const b = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Save', value: 'ok', type: 'primary' }] });
|
||||
$('.dashboard-dialog-input').last().val('typed').trigger($.Event('keydown', { key: 'Enter' }));
|
||||
await expect(b).resolves.toBe('typed');
|
||||
|
||||
const c = UIDashboardDialog({ message: 'm', input: {}, buttons: [{ label: 'Cancel', value: false }, { label: 'Save', value: 'ok', type: 'primary' }] });
|
||||
$('.dashboard-dialog-btn-quiet').last().trigger('click');
|
||||
await expect(c).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('escapes the title and button labels but trusts the message as HTML', () => {
|
||||
UIDashboardDialog({ title: '<b>x</b>', message: '<p>ok</p>', buttons: [{ label: '<i>y</i>', value: 1 }] });
|
||||
expect($('.dashboard-dialog-title').html()).toBe('<b>x</b>');
|
||||
expect($('.dashboard-dialog-btn').html()).toBe('<i>y</i>');
|
||||
expect($('.dashboard-dialog-message p')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mounts into the given container and marks the tone', () => {
|
||||
document.body.innerHTML = '<div class="host"></div>';
|
||||
UIDashboardDialog({ message: 'm', tone: 'danger', $container: $('.host') });
|
||||
expect($('.host .dashboard-dialog-overlay')).toHaveLength(1);
|
||||
expect($('.dashboard-dialog').hasClass('dashboard-dialog-danger')).toBe(true);
|
||||
expect($('.dashboard-dialog-icon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes itself from the DOM after the exit transition', async () => {
|
||||
vi.useFakeTimers();
|
||||
const p = UIDashboardDialog({ message: 'm' });
|
||||
$('.dashboard-dialog-btn').trigger('click');
|
||||
await p;
|
||||
expect(overlay()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(overlay()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -203,3 +203,68 @@ export function auditSlice (entries, page, size) {
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp off the wire as a Date, or `null` when it is not one. Team
|
||||
* audit rows carry unix seconds; members and teams carry an ISO string, so
|
||||
* one reader covers both rather than each column guessing.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {Date | null}
|
||||
*/
|
||||
export function parseTimestamp (value) {
|
||||
if ( value === null || value === undefined || value === '' ) return null;
|
||||
let date;
|
||||
if ( typeof value === 'number' ) {
|
||||
// Anything below 1e12 cannot be milliseconds for a date after 2001.
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else if ( typeof value === 'string' && /^\d+$/.test(value) ) {
|
||||
return parseTimestamp(Number(value));
|
||||
} else {
|
||||
date = new Date(/** @type {string} */ (value));
|
||||
}
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The letter an avatar tile shows for a name. Uppercased so a lowercase
|
||||
* username and a display name read the same in a row of tiles.
|
||||
*
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function initialOf (name) {
|
||||
const text = typeof name === 'string' ? name.trim() : '';
|
||||
if ( ! text ) return '?';
|
||||
// A leading handle sigil or bracket says nothing about the name.
|
||||
const letter = text.replace(/^[^\p{L}\p{N}]+/u, '') || text;
|
||||
return [...letter][0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which billing sentence fits the summary. The count of suspended accounts
|
||||
* only earns a mention when there are any -- "0 suspended cost nothing" is
|
||||
* noise on a healthy team.
|
||||
*
|
||||
* @param {{ billed: number, disabled: number }} summary
|
||||
* @returns {'teams_billing_summary_one'|'teams_billing_summary'|'teams_billing_summary_one_none'|'teams_billing_summary_none'}
|
||||
*/
|
||||
export function billingSummaryKey (summary) {
|
||||
const one = summary?.billed === 1;
|
||||
if ( (summary?.disabled ?? 0) > 0 ) return one ? 'teams_billing_summary_one' : 'teams_billing_summary';
|
||||
return one ? 'teams_billing_summary_one_none' : 'teams_billing_summary_none';
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable hue for a name's avatar tile, so the same account gets the same
|
||||
* colour in every row and the tiles are told apart at a glance.
|
||||
*
|
||||
* @param {unknown} name
|
||||
* @returns {number} degrees, 0-359
|
||||
*/
|
||||
export function avatarHue (name) {
|
||||
const text = typeof name === 'string' ? name : '';
|
||||
let hash = 0;
|
||||
for ( const ch of text ) hash = (hash * 31 + ch.codePointAt(0)) % 360;
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
auditSlice,
|
||||
avatarHue,
|
||||
billingSummaryKey,
|
||||
canDeleteAccount,
|
||||
auditReasonKey,
|
||||
initialOf,
|
||||
memberStatesFromAudit,
|
||||
parseTimestamp,
|
||||
membersBillingSummary,
|
||||
memberPlanLabel,
|
||||
sortMembers,
|
||||
@@ -253,3 +257,65 @@ describe('paging the record', () => {
|
||||
expect(auditSlice(rows(3), 0, 0).items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimestamp', () => {
|
||||
it('reads the unix seconds the audit routes send', () => {
|
||||
expect(parseTimestamp(1789672096)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
});
|
||||
|
||||
it('reads milliseconds and ISO strings, which members and teams use', () => {
|
||||
expect(parseTimestamp(1789672096000)?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
expect(parseTimestamp('2026-09-17T19:08:16Z')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
expect(parseTimestamp('1789672096')?.toISOString()).toBe('2026-09-17T19:08:16.000Z');
|
||||
});
|
||||
|
||||
it('is null for nothing or garbage, so a cell prints empty rather than 1970 or Invalid Date', () => {
|
||||
expect(parseTimestamp(null)).toBeNull();
|
||||
expect(parseTimestamp(undefined)).toBeNull();
|
||||
expect(parseTimestamp('')).toBeNull();
|
||||
expect(parseTimestamp('soon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialOf', () => {
|
||||
it('uppercases the first letter', () => {
|
||||
expect(initialOf('dana')).toBe('D');
|
||||
expect(initialOf('Acme Design')).toBe('A');
|
||||
});
|
||||
|
||||
it('skips a leading sigil and copes with non-Latin names', () => {
|
||||
expect(initialOf('@acme')).toBe('A');
|
||||
expect(initialOf(' élan')).toBe('É');
|
||||
expect(initialOf('日本')).toBe('日');
|
||||
});
|
||||
|
||||
it('falls back to a question mark for nothing', () => {
|
||||
expect(initialOf('')).toBe('?');
|
||||
expect(initialOf(undefined)).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatarHue', () => {
|
||||
it('is stable for a name and within the colour wheel', () => {
|
||||
expect(avatarHue('dana')).toBe(avatarHue('dana'));
|
||||
expect(avatarHue('dana')).toBeGreaterThanOrEqual(0);
|
||||
expect(avatarHue('dana')).toBeLessThan(360);
|
||||
expect(avatarHue(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('usually differs between neighbouring names', () => {
|
||||
expect(avatarHue('dana')).not.toBe(avatarHue('elliot'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('billingSummaryKey', () => {
|
||||
it('drops the suspended clause when nobody is suspended', () => {
|
||||
expect(billingSummaryKey({ billed: 3, disabled: 0 })).toBe('teams_billing_summary_none');
|
||||
expect(billingSummaryKey({ billed: 1, disabled: 0 })).toBe('teams_billing_summary_one_none');
|
||||
});
|
||||
|
||||
it('keeps it when there is something to say', () => {
|
||||
expect(billingSummaryKey({ billed: 3, disabled: 2 })).toBe('teams_billing_summary');
|
||||
expect(billingSummaryKey({ billed: 1, disabled: 1 })).toBe('teams_billing_summary_one');
|
||||
});
|
||||
});
|
||||
|
||||
+1132
-162
File diff suppressed because it is too large
Load Diff
@@ -562,6 +562,14 @@ const en = {
|
||||
teams_create_team_hint:
|
||||
'A team pays for the accounts you create in it. You stay its only administrator.',
|
||||
teams_create_team_prompt: 'What should the team be called?',
|
||||
teams_role_owner: 'Owner',
|
||||
teams_role_member: 'Member',
|
||||
teams_member_count_one: '1 account',
|
||||
teams_member_count: '{{count}} accounts',
|
||||
teams_member_account: 'Account',
|
||||
teams_credential_copied: 'Copied',
|
||||
teams_billing_summary_one_none: 'You’re billed for 1 active account.',
|
||||
teams_billing_summary_none: 'You’re billed for {{billed}} active accounts.',
|
||||
email_needed_to_create_token: 'This account has no email address, which tokens require.',
|
||||
payments_not_configured: 'Payments are not configured on this deployment.',
|
||||
could_not_start_checkout: 'Could not start checkout. Please try again.',
|
||||
|
||||
Reference in New Issue
Block a user