forked from fa/breadcrumb-the-shire
refactor(admin-lists): slim tenant + user grids, Stripe-style pagination
Tenant list: - Drop the avatar/logo column — the list now shows only Tenant name and user count. Also drop the logo-hasLogo server lookup and the initials/placeholder markup. User list (13 → 4 columns, Stripe pattern): - User (compound: avatar + Name + email subtitle, click opens drawer) - Tenants (badges, primary tooltip) - Last login (relative badge) - State (active/inactive badge) - UUID hidden at the last column for cells[uuidIndex].data - Everything else (departments, roles, phone, mobile, short_dial, created, modified) lives in the detail drawer. - Document the off-by-one gotcha: with row selection enabled gridjs prepends a checkbox cell, so runtime cells[] are shifted by +1; uuidIndex is 5 (column position 4 + selection offset). - New .grid-user-profile css mirrors .grid-tenant-profile (avatar + stacked name/email) with ellipsis and primary-color hover affordance. Pagination (all Grid.js lists): - Non-current page buttons now use the neutral-chip secondary-outline tokens (--app-button-neutral-*) plus the raised box-shadow — matches the rest of the button system in both light and dark themes. - Current page stays distinctly primary-filled with the filled-shadow; focus-visible combines the chip shadow with the primary focus ring. - Disabled pager buttons remain neutral but lose the lift shadow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,12 +248,15 @@
|
||||
same custom properties, not by setting `color`/`background-color`
|
||||
directly, and we must use a token that isn't shadowed inside the button
|
||||
scope (`--app-form-element-color` fits). */
|
||||
/* Align pagination buttons with the Stripe-style neutral-chip secondary
|
||||
outline: theme-aware surface, high-contrast text, subtle border,
|
||||
raised shadow. Current page stays distinct via primary fill. */
|
||||
.gridjs-pagination .gridjs-pages button,
|
||||
.gridjs-pagination .gridjs-pages button[type="button"] {
|
||||
--app-background-color: var(--app-form-element-background-color);
|
||||
--app-color: var(--app-form-element-color);
|
||||
--app-border-color: var(--app-table-border-color);
|
||||
--app-box-shadow: none;
|
||||
--app-background-color: var(--app-button-neutral-bg);
|
||||
--app-color: var(--app-button-neutral-color);
|
||||
--app-border-color: var(--app-button-neutral-border);
|
||||
box-shadow: var(--app-button-neutral-shadow);
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -263,21 +266,24 @@
|
||||
line-height: 1.3;
|
||||
min-width: 2rem;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.gridjs-pagination .gridjs-pages button:hover:not(:disabled),
|
||||
.gridjs-pagination .gridjs-pages button[type="button"]:hover:not(:disabled),
|
||||
.gridjs-pagination .gridjs-pages button:focus:not(:disabled),
|
||||
.gridjs-pagination .gridjs-pages button[type="button"]:focus:not(:disabled) {
|
||||
--app-background-color: var(--app-primary-hover-background);
|
||||
--app-border-color: var(--app-primary-hover-border);
|
||||
--app-color: var(--app-primary-inverse);
|
||||
--app-background-color: var(--app-button-neutral-bg-hover);
|
||||
--app-color: var(--app-button-neutral-color-hover);
|
||||
--app-border-color: var(--app-button-neutral-border-hover);
|
||||
box-shadow: var(--app-button-neutral-shadow-hover);
|
||||
}
|
||||
|
||||
.gridjs-pagination .gridjs-pages button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus);
|
||||
box-shadow:
|
||||
var(--app-button-neutral-shadow),
|
||||
0 0 0 var(--app-outline-width) var(--app-primary-focus);
|
||||
}
|
||||
|
||||
.gridjs-pagination .gridjs-pages button.gridjs-currentPage,
|
||||
@@ -287,16 +293,18 @@
|
||||
--app-background-color: var(--app-primary);
|
||||
--app-border-color: var(--app-primary);
|
||||
--app-color: var(--app-primary-inverse);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--app-button-filled-shadow);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.gridjs-pagination .gridjs-pages button:disabled,
|
||||
.gridjs-pagination .gridjs-pages button[disabled] {
|
||||
--app-background-color: var(--app-background-color);
|
||||
--app-background-color: var(--app-button-neutral-bg);
|
||||
--app-color: var(--app-muted-color);
|
||||
--app-border-color: var(--app-table-border-color);
|
||||
--app-border-color: var(--app-button-neutral-border);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.gridjs-pagination .gridjs-pages button.gridjs-spread,
|
||||
@@ -634,6 +642,42 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Compound user cell: avatar + name (strong) + email subtitle (small, muted). */
|
||||
.grid-user-profile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-user-profile-text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.grid-user-profile-text strong {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--app-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid-user-profile-text small {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-muted-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.grid-user-profile.app-grid-link-cell:hover .grid-user-profile-text strong {
|
||||
color: var(--app-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gridjs-container .grid-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -664,6 +708,7 @@
|
||||
.gridjs-td .badge,
|
||||
.gridjs-td .grid-name-cell,
|
||||
.gridjs-td .grid-tenant-profile,
|
||||
.gridjs-td .grid-user-profile,
|
||||
.gridjs-td .app-inline-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,6 @@ createListPageModule({
|
||||
const labels = config.labels ?? {};
|
||||
|
||||
const tenantUuidIndex = 2;
|
||||
const initialsForRow = (row) => {
|
||||
const descriptionCell = row?.cells?.[0]?.data;
|
||||
const label = typeof descriptionCell === 'object' && descriptionCell !== null
|
||||
? String(descriptionCell.label ?? '')
|
||||
: String(descriptionCell ?? '');
|
||||
const initial = label.trim().charAt(0).toUpperCase();
|
||||
return initial || '?';
|
||||
};
|
||||
|
||||
const { gridConfig } = initListPage({
|
||||
grid: {
|
||||
@@ -32,27 +24,7 @@ createListPageModule({
|
||||
name: labels.tenant || 'Tenant',
|
||||
sort: true,
|
||||
width: '75%',
|
||||
formatter: (cell, row) => {
|
||||
const hasLogo = cell?.has_logo ? 1 : 0;
|
||||
const uuid = encodeURIComponent(String(cell?.uuid ?? ''));
|
||||
let avatarHtml = '';
|
||||
if (hasLogo && uuid) {
|
||||
const src = new URL(`admin/tenants/logo-file?uuid=${uuid}&theme=light&size=64`, appBase).toString();
|
||||
avatarHtml = `<img class="grid-avatar grid-avatar-tenant" src="${src}" alt="" loading="lazy">`;
|
||||
} else {
|
||||
const initials = escapeHtml(initialsForRow(row));
|
||||
avatarHtml = `<span class="grid-avatar grid-avatar-tenant grid-avatar-placeholder">${initials}</span>`;
|
||||
}
|
||||
|
||||
const nameText = escapeHtml(String(cell?.label ?? ''));
|
||||
|
||||
return gridjs.html(
|
||||
`<div class="grid-tenant-profile">` +
|
||||
`${avatarHtml}` +
|
||||
`<span class="grid-tenant-profile-name-text">${nameText}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
},
|
||||
formatter: (cell) => gridjs.html(escapeHtml(String(cell ?? ''))),
|
||||
},
|
||||
{
|
||||
name: labels.users || 'Users',
|
||||
@@ -72,11 +44,7 @@ createListPageModule({
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang ?? {},
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
{
|
||||
uuid: row.uuid ?? '',
|
||||
label: row.description ?? '',
|
||||
has_logo: row.has_logo ? 1 : 0,
|
||||
},
|
||||
row.description ?? '',
|
||||
row.total_users ?? 0,
|
||||
row.uuid,
|
||||
]),
|
||||
|
||||
@@ -26,10 +26,20 @@ createListPageModule({
|
||||
if (!rowSelection) {
|
||||
throw new Error('Grid.js RowSelection plugin is not available.');
|
||||
}
|
||||
const activeIndex = 6;
|
||||
const uuidIndex = 2;
|
||||
|
||||
// Layout:
|
||||
// col 0: User compound (display)
|
||||
// col 1: Tenants
|
||||
// col 2: Last login
|
||||
// col 3: State
|
||||
// col 4: UUID (hidden scalar)
|
||||
// Selection plugin prepends a checkbox cell, so runtime cells[] are
|
||||
// shifted by +1. Cell accessors therefore use indices 1..5 (1-based
|
||||
// from the user-visible column order + 1 for the selection col).
|
||||
const uuidIndex = 5;
|
||||
const activeIndex = 4;
|
||||
const canEditRow = (row) => {
|
||||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||||
const uuid = String(row?.cells?.[uuidIndex]?.data ?? '');
|
||||
if (!uuid) { return false; }
|
||||
if (uuid === currentUserUuid) {
|
||||
return canUpdateUsers || canUpdateSelf;
|
||||
@@ -37,10 +47,8 @@ createListPageModule({
|
||||
return canUpdateUsers;
|
||||
};
|
||||
|
||||
const initialsForRow = (row) => {
|
||||
const first = row?.cells?.[3]?.data ?? '';
|
||||
const last = row?.cells?.[4]?.data ?? '';
|
||||
const parts = [first, last]
|
||||
const initialsFromUser = (user) => {
|
||||
const parts = [user?.firstName, user?.lastName]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const chars = parts.map((value) => value[0] || '').join('').toUpperCase();
|
||||
@@ -49,32 +57,38 @@ createListPageModule({
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: labels.avatar || 'Avatar',
|
||||
sort: false,
|
||||
formatter: (cell, row) => {
|
||||
if (!cell) {
|
||||
const initials = escapeHtml(initialsForRow(row));
|
||||
return gridjs.html(`<span class="grid-avatar grid-avatar-placeholder">${initials}</span>`);
|
||||
}
|
||||
const src = new URL(`admin/users/avatar-file?uuid=${cell}&size=64`, appBase).toString();
|
||||
const full = new URL(`admin/users/avatar-file?uuid=${cell}&size=256`, appBase).toString();
|
||||
return gridjs.html(`<a data-fslightbox="user-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>`);
|
||||
},
|
||||
},
|
||||
{ name: 'UUID', hidden: true },
|
||||
{
|
||||
name: labels.firstName || 'First name',
|
||||
name: labels.user || 'User',
|
||||
sort: true,
|
||||
formatter: (cell, row) => {
|
||||
const name = escapeHtml(String(cell || '').trim() || '-');
|
||||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||||
if (!uuid) { return gridjs.html(`<span>${name}</span>`); }
|
||||
const uuid = String(row?.cells?.[uuidIndex]?.data ?? '');
|
||||
const firstName = String(cell?.firstName ?? '').trim();
|
||||
const lastName = String(cell?.lastName ?? '').trim();
|
||||
const email = String(cell?.email ?? '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || email || '-';
|
||||
const avatarHtml = cell?.hasAvatar && uuid
|
||||
? `<img class="grid-avatar" src="${new URL(`admin/users/avatar-file?uuid=${encodeURIComponent(uuid)}&size=64`, appBase).toString()}" alt="" loading="lazy">`
|
||||
: `<span class="grid-avatar grid-avatar-placeholder">${escapeHtml(initialsFromUser(cell))}</span>`;
|
||||
const nameEsc = escapeHtml(fullName);
|
||||
const emailEsc = escapeHtml(email);
|
||||
const subtitle = email && email !== fullName ? `<small>${emailEsc}</small>` : '';
|
||||
const inner = `${avatarHtml}<span class="grid-user-profile-text"><strong>${nameEsc}</strong>${subtitle}</span>`;
|
||||
if (!uuid) {
|
||||
return gridjs.html(`<div class="grid-user-profile">${inner}</div>`);
|
||||
}
|
||||
const editUrl = escapeHtml(withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()));
|
||||
return gridjs.html(`<a class="app-grid-link-cell" href="${editUrl}" data-drawer-trigger>${name}</a>`);
|
||||
return gridjs.html(`<a class="grid-user-profile app-grid-link-cell" href="${editUrl}" data-drawer-trigger>${inner}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.lastName || 'Last name', sort: true },
|
||||
{ name: labels.email || 'Email', sort: true },
|
||||
{
|
||||
name: labels.tenants || 'Tenants',
|
||||
sort: false,
|
||||
formatter: (cell) => gridjs.html(buildBadgeList(cell, { primaryTooltip: labels.primaryTenant || '' })),
|
||||
},
|
||||
{
|
||||
name: labels.lastLogin || 'Last login',
|
||||
sort: true,
|
||||
formatter: (cell) => badgeHtml(gridjs, cell || labels.never || 'Never'),
|
||||
},
|
||||
{
|
||||
name: labels.state || 'State',
|
||||
sort: true,
|
||||
@@ -82,24 +96,7 @@ createListPageModule({
|
||||
? gridjs.html(`<span class="badge" data-variant="success">${labels.active || 'Active'}</span>`)
|
||||
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive || 'Inactive'}</span>`),
|
||||
},
|
||||
{
|
||||
name: labels.tenants || 'Tenants',
|
||||
sort: false,
|
||||
formatter: (cell) => gridjs.html(buildBadgeList(cell, { primaryTooltip: labels.primaryTenant || '' })),
|
||||
},
|
||||
{ name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.mobile || 'Mobile', sort: false },
|
||||
{ name: labels.shortDial || 'Short dial', sort: false },
|
||||
{ name: labels.created || 'Created', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
|
||||
{ name: labels.modified || 'Modified', sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
|
||||
{
|
||||
name: labels.lastLogin || 'Last login',
|
||||
sort: true,
|
||||
formatter: (cell) => badgeHtml(gridjs, cell || labels.never || 'Never'),
|
||||
},
|
||||
{ name: 'ID', hidden: true },
|
||||
{ name: 'UUID', hidden: true },
|
||||
];
|
||||
|
||||
const { gridConfig } = initListPage({
|
||||
@@ -109,26 +106,20 @@ createListPageModule({
|
||||
dataUrl: 'admin/users/data',
|
||||
appBase,
|
||||
columns,
|
||||
sortColumns: [null, null, 'first_name', 'last_name', 'email', 'active', null, null, null, null, null, null, 'created', 'modified', 'last_login_at', null],
|
||||
sortColumns: ['first_name', null, 'last_login_at', 'active', null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang ?? {},
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
row.has_avatar ? row.uuid : '',
|
||||
row.uuid,
|
||||
row.first_name,
|
||||
row.last_name,
|
||||
row.email,
|
||||
row.active,
|
||||
{
|
||||
firstName: row.first_name ?? '',
|
||||
lastName: row.last_name ?? '',
|
||||
email: row.email ?? '',
|
||||
hasAvatar: !!row.has_avatar,
|
||||
},
|
||||
row.tenants,
|
||||
row.departments,
|
||||
row.roles,
|
||||
row.phone,
|
||||
row.mobile,
|
||||
row.short_dial,
|
||||
row.created,
|
||||
row.modified,
|
||||
row.last_login,
|
||||
row.id,
|
||||
row.active,
|
||||
row.uuid ?? '',
|
||||
]),
|
||||
selection: {
|
||||
enabled: true,
|
||||
@@ -136,7 +127,7 @@ createListPageModule({
|
||||
component: rowSelection,
|
||||
selectAllLabel: labels.selectAll || 'Select all',
|
||||
props: {
|
||||
id: (row) => row.cell(uuidIndex).data,
|
||||
id: (row) => String(row.cell(uuidIndex).data ?? ''),
|
||||
},
|
||||
getSelectedIds: ({ container }) => {
|
||||
if (!container) { return []; }
|
||||
@@ -149,7 +140,7 @@ createListPageModule({
|
||||
filterSchema,
|
||||
urlSync: true,
|
||||
rowDataset: (row) => ({
|
||||
uuid: row?.cells?.[uuidIndex]?.data,
|
||||
uuid: String(row?.cells?.[uuidIndex]?.data ?? ''),
|
||||
}),
|
||||
rowInteraction: { linkColumn: false },
|
||||
rowDblClick: {
|
||||
@@ -157,7 +148,7 @@ createListPageModule({
|
||||
if (!canEditRow(rowData)) {
|
||||
return '';
|
||||
}
|
||||
const uuid = rowData?.cells?.[uuidIndex]?.data;
|
||||
const uuid = String(rowData?.cells?.[uuidIndex]?.data ?? '');
|
||||
return uuid ? new URL(`admin/users/edit/${uuid}`, appBase).toString() : '';
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user