Files
safe-wallet/apps/web/e2e/tests/regression/spaces-members-table.spec.ts
ec936db3e8 refactor(spaces): unify + harden Members and Address book tables (shadcn, responsive, variant-styled) (#8108)
* refactor(spaces): unify Team and Address book tables on shadcn

Both the Spaces "Team" (Members) list and the Address book now render
through a shared PaginatedDataTable built on shadcn table primitives,
replacing the MUI EnhancedTable in MembersList. This keeps each table's
own functionality (member edit/renew/remove, address-book actions,
duplicate dimming, added-by/last-updated column) while sharing one
paginated, consistently-styled table shell.

- Add space between the tabs and the table on the Members page
- Wrap both tables in a rounded `bg-card` container, drop the border line
- Convert member role/declined/expired chips to shadcn Badge and the
  email cell to a shadcn Tooltip with truncation

* style(spaces): use muted color for data table column headers

Render the shared PaginatedDataTable column headers with
text-muted-foreground so the header titles read as a subtle gray in
light mode (and a lighter gray in dark mode) instead of the strong
foreground color.

* refactor(spaces): drop MUI from member row actions

Replace the MUI Tooltip/IconButton/SvgIcon in the members Edit and Remove
buttons with shadcn Tooltip + Button, rendering the existing edit/delete
SVGs via fill-current so no MUI is used in MembersList.

* feat(spaces): restore column sorting in the members table

Add opt-in, per-column sorting to the shared PaginatedDataTable (asc →
desc → unsorted, with empty values sorted last) and enable it on the
members Name, Email and Role columns, restoring the sorting the previous
MUI table provided.

* feat(spaces): make address book table sortable, drop redundant name tooltip

Enable column sorting on the address book (name, address, chains by
count, added by / last updated) so both spaces tables sort by default,
and remove the tooltip on the name cell that just repeated the visible
name.

* refactor(spaces): stabilize members data, harden table paging, extract MemberRow

- Memoize useSpaceMembersByStatus (and use a stable empty array) so the
  members lists keep a stable identity; without this the shared table's
  reset-on-rows-change effect could bounce a paginated members list back
  to the first page on unrelated re-renders.
- Clamp the current page in PaginatedDataTable so a shrinking data set
  can never strand the user on an empty page, and pull the sort
  indicator into a small SortIcon/ariaSortValue helper.
- Extract the inline members renderRow into a MemberRow component.

* refactor(spaces): drop redundant per-component dark wrappers, inherit from root ShadcnProvider

The Members and Address book components re-derived dark mode via useDarkMode()
and re-applied a `shadcn-scope`/`dark` wrapper, but the whole app is already
wrapped in <ShadcnProvider dark={isDarkMode}> at the root (_app.tsx). Since the
shadcn CSS variables inherit, the dark theme already cascades down — the local
wrappers were redundant. Rely on the root provider instead (dark mode set
globally, cascading through stylesheets).

* feat(spaces): make Members and Address book tables responsive on mobile

Hide secondary columns below 768px and surface their data elsewhere so the
shared PaginatedDataTable no longer overflows on small screens:

- PaginatedDataTable: renderRow -> renderCell(row, column) so the component
  owns each cell and injects responsive classes once per column. New column
  fields: priority ('secondary' hides <768px), sticky (mobile-only pin),
  minWidth, cellClassName, cellTestId. Optional renderRowDetail collapsible
  mobile row. md:table-fixed keeps desktop proportions + truncation; mobile
  uses auto layout. Pagination buttons get aria-labels.
- Members: email hides on mobile and shows under the name; actions collapse
  into a kebab (MemberRowActionsMenu). Extracted useRenewInvite, shared by the
  desktop button and the kebab (renew analytics Track preserved on both).
- Address book: name pinned, address shortened on mobile, chains + "Added by"
  move into the detail row; actions become a kebab on mobile.

Mobile sorting uses the visible column headers only (no separate control).

* refactor(spaces): co-locate cell renderers with column config in PaginatedDataTable

Replace the table-level renderCell(row, column) switch with a per-column
cell(row) renderer on DataTableColumn. Drops the magic-string `column.id`
switches in MembersList and SpaceAddressBookTable — each column now owns its
own rendering next to its config. `id` stays for React keys and sort state.

* refactor(spaces): replace open className escape hatches with bounded column variants

Decouple styling from the table's data model: DataTableColumn no longer takes
free-form className/cellClassName strings (the drift vector). Styling is now a
bounded, CVA-backed API — align ('start'|'center'|'end'), emphasis
('default'|'strong'), and width (a fixed scale) — so a consumer cannot type a
one-off color/radius/font into a column. Layout/visuals are unchanged
(align:'end' == text-right, emphasis:'strong' == font-bold, width == md:w-[N%]).

First step of the design-system table direction; promoting these variants onto
the shared ui/table.tsx primitive app-wide is the follow-up.

* tests(spaces): drop dead useDarkMode mock from Members test

The Members component no longer consumes useDarkMode (dark mode now
cascades from the root ShadcnProvider), so the mock was unused.

* fix(spaces): uniform tab-to-content spacing in Address book

The Workspace/My contacts tabs rendered an action bar (mt-4/mb-4)
between the tabs and the card, while the Pending tab had none, leaving
its card flush against the tabs. Use a constant mt-6 gap on both the
action bar and the content card so the spacing matches across all three
tabs and both data/empty states, consistent with the Members view.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-01 15:19:05 +02:00

112 lines
4.3 KiB
TypeScript

/**
* Regression — the workspace Members table renders members and sorts by column.
*
* This PR rewrote the Members (Team) table off the legacy MUI `EnhancedTable`
* onto the shared `PaginatedDataTable` and added client-side column sorting.
* This guards the core team-management view: members must render as rows, and
* clicking the sortable "Name" header must re-order them (asc on first click).
*
* Setup mirrors the proven spaces pattern (see recipient-dropdown-network-filter):
* a faked Spaces session via localStorage + a mocked `/v1/auth/me` (so the
* session-expiry guard doesn't clear it) + mocked spaces endpoints. The Members
* page is gated on the SIWE session, not a connected wallet, so no wallet is
* connected here. Members are returned in a deliberately non-alphabetical order
* (Charlie, Alice, Bob) so the sort is observable.
*
* Run: yarn workspace @safe-global/web pw:test spaces-members-table
* Tag: @regression — runs under the chromium project, on demand.
*/
import { test, expect } from '../../src/fixtures/test.fixture'
import { LS_NAMESPACE } from '../../src/data/constants'
const SIGNER = '0x1234567890123456789012345678901234567890'
const SPACE_ID = '00000000-0000-0000-0000-0000000000aa'
const SPACE = {
id: 1,
uuid: SPACE_ID,
name: 'Test Workspace',
status: 'ACTIVE',
safeCount: 0,
members: [{ id: 1, role: 'ADMIN', name: 'Admin', status: 'ACTIVE', user: { id: 1, status: 'ACTIVE' } }],
}
// Deliberately non-alphabetical so the Name sort is observable.
const member = (id: number, name: string, email: string) => ({
id,
role: 'MEMBER',
status: 'ACTIVE',
name,
invitedBy: null,
inviteExpiresAt: null,
createdAt: '',
updatedAt: '',
user: { id, status: 'ACTIVE', email },
})
const MEMBERS = [
member(1, 'Charlie', '[email protected]'),
member(2, 'Alice', '[email protected]'),
member(3, 'Bob', '[email protected]'),
]
const API_ORDER = ['Charlie', 'Alice', 'Bob']
const NAME_ASC = ['Alice', 'Bob', 'Charlie']
test.describe('Spaces — Members table', { tag: '@regression' }, () => {
test('should render workspace members and sort them by name when the Name header is clicked', async ({
safePage,
walletPage,
}) => {
// Fake a Spaces session so the SIWE-gated workspace pages render.
await safePage.addInitScript(
({ ns, spaceId }) => {
window.localStorage.setItem(
`${ns}auth`,
JSON.stringify({
sessionExpiresAt: Date.now() + 24 * 60 * 60 * 1000,
lastUsedSpace: spaceId,
isStoreHydrated: false,
cfSafeSynced: false,
isOidcLoginPending: false,
}),
)
},
{ ns: LS_NAMESPACE, spaceId: SPACE_ID },
)
// Mock auth + spaces endpoints. The members endpoint returns the rows the
// table renders; everything else is the minimum to establish the session.
await safePage.route(/\/v1\/auth\/me(\?.*)?$/, (route) =>
route.fulfill({ json: { id: '1', authMethod: 'siwe', signerAddress: SIGNER } }),
)
await safePage.route(/\/v1\/users(\?.*)?$/, (route) =>
route.fulfill({ json: { id: 1, status: 1, wallets: [{ id: 1, address: SIGNER }] } }),
)
await safePage.route(/\/v1\/spaces\/[^/]+\/members(\?.*)?$/, (route) =>
route.fulfill({ json: { members: MEMBERS } }),
)
await safePage.route(/\/v1\/spaces\/[^/]+\/safes(\?.*)?$/, (route) => route.fulfill({ json: { safes: {} } }))
await safePage.route(/\/v1\/spaces\/[^/]+(\?.*)?$/, (route) => route.fulfill({ json: SPACE }))
await safePage.route(/\/v1\/spaces(\?.*)?$/, (route) => route.fulfill({ json: [SPACE] }))
await safePage.goto(`/spaces/members?spaceId=${SPACE_ID}`)
await walletPage.acceptCookies()
// All three members render as rows in the (now shared) table.
const nameCells = safePage.getByTestId('table-cell-name')
await expect(nameCells).toHaveCount(MEMBERS.length)
for (const name of API_ORDER) {
await expect(nameCells.filter({ hasText: name })).toBeVisible()
}
// Rows start in API order…
await expect(nameCells.first()).toContainText(API_ORDER[0]!)
// …and sort ascending by name when the sortable Name header is clicked.
await safePage.getByRole('button', { name: /Name/ }).click()
await expect(nameCells.first()).toContainText(NAME_ASC[0]!)
await expect(nameCells.last()).toContainText(NAME_ASC[NAME_ASC.length - 1]!)
})
})