From 4de1d1fb39930a437ea3b0ee6937f90cfd3081f6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 21 Sep 2026 09:30:03 -0700 Subject: [PATCH] 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. --- src/gui/src/UI/Dashboard/TabTeams.js | 478 +++--- .../src/UI/Dashboard/TabTeams.render.test.js | 190 +++ src/gui/src/UI/Dashboard/TabTeams.test.js | 6 +- src/gui/src/UI/Dashboard/UIDashboardDialog.js | 172 +++ .../UI/Dashboard/UIDashboardDialog.test.js | 116 ++ src/gui/src/UI/Dashboard/teamsConsole.js | 65 + src/gui/src/UI/Dashboard/teamsConsole.test.js | 66 + src/gui/src/css/dashboard.css | 1294 ++++++++++++++--- src/gui/src/i18n/translations/en.js | 8 + 9 files changed, 2063 insertions(+), 332 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/TabTeams.render.test.js create mode 100644 src/gui/src/UI/Dashboard/UIDashboardDialog.js create mode 100644 src/gui/src/UI/Dashboard/UIDashboardDialog.test.js diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 9141c354c..e651b4a2d 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -17,17 +17,20 @@ * along with this program. If not, see . */ -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) => + ``; +const ICONS = { + team: SVG(''), + directory: SVG(''), + copy: SVG(''), + check: SVG(''), + close: SVG(''), + warning: SVG(''), +}; + // -- Rendering ------------------------------------------------------------ +/** A lettered tile in place of a picture: team accounts have none. */ +const renderAvatar = (name, extraClass = '') => { + const hue = avatarHue(name); + return ``; +}; + const renderTeamPicker = () => { if ( state.teams.length < 2 ) return ''; - let h = '
'; - h += ``; - h += '`; for ( const team of state.teams ) { const selected = team.uid === state.selected?.uid ? ' selected' : ''; h += ``; } - h += '
'; + h += ''; + 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 = '
'; + h += '
'; + h += renderAvatar(teamName(team), ' teams-avatar-lg'); + h += '
'; + h += '
'; + h += `${html_encode(teamName(team))}`; + h += `${i18n(team.isOwner ? 'teams_role_owner' : 'teams_role_member')}`; + h += '
'; + 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 += `${meta.join('')}`; + h += '
'; + const picker = renderTeamPicker(); + const rename = team.isOwner ? `` : ''; + if ( picker || rename ) h += `
${picker}${rename}
`; + h += '
'; + return h; +}; + +const renderCardHead = (title, hint, count = null) => { + let h = '
'; + h += `

${title}`; + if ( count !== null ) h += `${count}`; + h += '

'; + if ( hint ) h += `

${hint}

`; + h += '
'; 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 `${i18n(`teams_member_state_${kind}`)}`; +}; + +const renderIdentity = (username, subline) => { + const you = username === window.user?.username; + let h = '
'; + h += renderAvatar(username); + h += '
'; + h += `${html_encode(username)}`; + if ( you ) h += `${i18n('share_you')}`; + h += ''; + if ( subline ) h += `${subline}`; + h += '
'; + return h; +}; + +const renderMemberActions = (member) => { + if ( ! member.orgOwned ) return ''; + const forMember = { 'data-username': member.username }; + let h = '
'; + 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 += '
'; + 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 = ``; - h += `${username}`; - h += `${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}`; - h += `${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}`; - h += `${html_encode(dateText(member.createdAt))}`; - h += `${planCell(member)}`; - h += ''; - 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 += ''; + h += `${renderIdentity(member.username, i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined'))}`; + h += `${renderStatus(member)}`; + h += `${html_encode(dateText(member.createdAt))}`; + h += `${planCell(member)}`; + h += `${renderMemberActions(member)}`; + h += ''; return h; }; @@ -142,20 +220,21 @@ const renderMembers = () => { const summary = membersBillingSummary(annotated); let h = '
'; - h += `

${i18n('teams_accounts')}

`; - const billingKey = summary.billed === 1 ? 'teams_billing_summary_one' : 'teams_billing_summary'; - h += `

${i18n(billingKey, { billed: summary.billed, disabled: summary.disabled })}

`; + h += renderCardHead( + i18n('teams_accounts'), + i18n(billingSummaryKey(summary), { billed: summary.billed, disabled: summary.disabled }), + annotated.length, + ); if ( annotated.length === 0 ) { h += `

${i18n('teams_no_accounts')}

`; } else { - h += '
'; - h += ``; - h += ``; + h += '
${i18n('username')}${i18n('teams_member_kind')}
'; + h += ``; h += ``; h += ``; h += ``; - h += ``; + h += ``; h += ''; for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); h += '
${i18n('teams_member_account')}${i18n('teams_member_state')}${i18n('teams_member_since')}${i18n('teams_member_plan')}${i18n('teams_member_actions')}${i18n('teams_member_actions')}
'; @@ -166,48 +245,52 @@ const renderMembers = () => { const renderAddAccount = () => { let h = '
'; - h += `

${i18n('teams_add_account')}

`; - h += `

${i18n('teams_add_account_hint')}

`; - h += `

${i18n('teams_add_account_email_hint')}

`; - h += '
'; - h += ``; - h += ``; - h += ``; + h += renderCardHead(i18n('teams_add_account'), i18n('teams_add_account_hint')); + h += '
'; + h += '
'; + h += ``; + h += ''; h += '
'; - h += ''; + h += '
'; + h += ``; + h += ''; + h += '
'; + h += ``; + h += '
'; + h += `

${i18n('teams_add_account_email_hint')}

`; + h += ''; h += '
'; return h; }; -const renderAudit = () => { +const renderAudit = ({ title, hint }) => { let h = '
'; - h += `

${i18n('teams_audit')}

`; - h += `

${i18n('teams_audit_hint')}

`; + h += renderCardHead(title, hint, state.audit.length || null); if ( state.audit.length === 0 ) { h += `

${i18n('teams_no_audit')}

`; } else { - h += '
'; - h += ``; - h += ``; - h += ``; - h += ``; - h += ''; const { items, page, pages, from, to, total } = auditSlice( state.audit, state.auditPage, AUDIT_ROWS_PER_PAGE, ); + h += '
${i18n('teams_audit_when')}${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}
'; + h += ``; + h += ``; + h += ``; + h += ``; + h += ''; for ( const entry of items ) { const actionKey = auditActionKey(entry.action); const reasonKey = auditReasonKey(entry.reason); h += ''; - h += ``; - h += `'; - h += ``; - h += ``; + h += ``; + h += ``; + h += ``; h += ''; } h += '
${i18n('teams_audit_action')}${i18n('teams_audit_account')}${i18n('teams_audit_actor')}${i18n('teams_audit_when')}
${html_encode(dateText(entry.createdAt))}${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; + h += `${actionKey ? i18n(actionKey) : html_encode(entry.action)}`; if ( reasonKey ) h += ` ${i18n(reasonKey)}`; h += '${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(entry.username ?? '')}${html_encode(entry.actorUsername ?? '')}${html_encode(dateTimeText(entry.createdAt))}
'; @@ -227,17 +310,13 @@ const renderAudit = () => { /** Colleagues, by name. No state, no dates, no actions — none are theirs. */ const renderRoster = () => { let h = '
'; - h += `

${i18n('teams_roster')}

`; - h += `

${i18n('teams_roster_hint')}

`; + h += renderCardHead(i18n('teams_roster'), i18n('teams_roster_hint'), state.members.length || null); if ( state.members.length === 0 ) { h += `

${i18n('teams_roster_empty')}

`; } else { h += '
    '; for ( const member of sortMembers(state.members) ) { - const you = member.username === window.user?.username; - h += `
  • ${html_encode(member.username)}`; - if ( you ) h += ` (${i18n('share_you')})`; - h += '
  • '; + h += `
  • ${renderIdentity(member.username)}
  • `; } h += '
'; } @@ -247,44 +326,37 @@ const renderRoster = () => { /** What a member sees: who else is here, their own entries, nothing admin. */ const renderMemberView = () => { - let h = '
'; - h += `

${i18n('teams_your_record')}

`; - h += `

${i18n('teams_your_record_hint', { team: teamName(state.selected) })}

`; - h += '
'; - 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 = '
'; - h += `

${i18n('teams_directory')}

`; - h += `

${i18n('teams_directory_hint')}

`; - h += '