mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 06:41:55 +00:00
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.
This commit is contained in:
1 parent
b39bb771d9
commit
edaf70aa5c
6 files changed
+1447
-300
No files matched your search
@@ -23,11 +23,15 @@ import teamActionButton from './teamActionIcons.js';
|
||||
import {
|
||||
annotateMembers,
|
||||
auditActionKey,
|
||||
auditSlice,
|
||||
canDeleteAccount,
|
||||
auditReasonKey,
|
||||
auditSlice,
|
||||
avatarHue,
|
||||
billingSummaryKey,
|
||||
canDeleteAccount,
|
||||
initialOf,
|
||||
membersBillingSummary,
|
||||
memberPlanLabel,
|
||||
parseTimestamp,
|
||||
sortMembers,
|
||||
} from './teamsConsole.js';
|
||||
|
||||
@@ -51,9 +55,12 @@ const modalOptions = ($el_window) => ({
|
||||
stay_on_top: true,
|
||||
});
|
||||
|
||||
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 +70,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 +142,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 +226,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 +251,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 +316,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 +332,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 +376,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) => {
|
||||
@@ -457,10 +563,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 +599,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 +610,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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -639,7 +768,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,191 @@
|
||||
// @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('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIPrompt.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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+842
-169
File diff suppressed because it is too large.
Load diff
@@ -559,6 +559,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