Skip to content

Commit 4c421a0

Browse files
chore: add data-testid attributes to members components (#1058) (#1062)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 286d7e0 commit 4c421a0

7 files changed

Lines changed: 37 additions & 32 deletions

File tree

.agents/conventions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,7 @@ Prefix with the domain to avoid collisions:
19361936
|---|---|---|
19371937
| Database | `db-` | `db-sort-button`, `db-filter-bar`, `db-row-{id}` |
19381938
| Editor | `editor-` | `editor-toolbar`, `editor-slash-menu`, `editor-image` |
1939+
| Members | `member-` / `invite-` / `pending-invite-` | `members-list`, `member-row-{userId}`, `invite-form`, `pending-invite-list` |
19391940
| Sidebar | `sidebar-` | `sidebar-tree`, `sidebar-search` |
19401941

19411942
For parameterized IDs, use kebab-case: `db-sort-rule-{index}`, `editor-slash-item-{name}`.

.agents/quality.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,6 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
141141
| 2026-05-11 | Reduce shared base JS below 150 kB (#1030). Replaced `next/dynamic` with `React.lazy` in providers, split lazy-loaded providers into separate chunk, disabled Sentry route manifest injection, consolidated client-side Sentry filters. Framework baseline: 152→150 kB gzipped. No test changes. Test totals unchanged: 139 Vitest files (1877 tests), 76 E2E specs (374 tests). |
142142
| 2026-05-12 | Fix workspace home header mobile overflow (#1038). Added `flex-wrap` and responsive button labels to prevent header buttons from overflowing on mobile. Added 1 new E2E spec: `e2e/workspace-home-mobile-header.spec.ts` (1 test). Updated `workspace-home.stories.tsx` with shared header component and MobileViewport story. Test totals: 139 Vitest files (1877 tests), 77 E2E specs (375 tests). |
143143
| 2026-05-12 | Add E2E tests for workspace home page interactions (#1041). Added 1 new E2E spec: `e2e/workspace-home.spec.ts` (6 tests) covering new page creation, filter by title, sort dropdown reorder, page list navigation, clear filter reset, and recently visited section. Test totals: 139 Vitest files (1877 tests), 78 E2E specs (381 tests). |
144+
| 2026-05-12 | Add data-testid attributes to members components (#1058). Added testids to member-list, invite-form, pending-invite-list, and role-select. Updated e2e/members.spec.ts to use testid selectors instead of fragile text/CSS selectors. No new test files. Test totals unchanged: 139 Vitest files (1877 tests), 78 E2E specs (381 tests). |
144145

145146

e2e/members.spec.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -108,21 +108,18 @@ test.describe("Workspace member management", () => {
108108
await goToMembersPage(page, workspaceSlug);
109109

110110
// The invite form should be visible (owner has admin privileges)
111-
const inviteSection = page.locator("text=Invite").first();
112-
await expect(inviteSection).toBeVisible();
111+
const inviteForm = page.locator('[data-testid="invite-form"]');
112+
await expect(inviteForm).toBeVisible();
113113

114114
// Fill in the invite form
115-
await page.fill("#invite-email", INVITE_EMAIL);
115+
await page.locator('[data-testid="invite-email-input"]').fill(INVITE_EMAIL);
116116

117117
// Submit the invite
118118
await page.getByRole("button", { name: "Invite", exact: true }).click();
119119

120-
// Wait for the invite link URL to appear below the form. The invite form
121-
// shows the link as text followed by a copy button. Scope to the form's
122-
// parent to avoid matching the pending invites table's copy buttons.
123-
const inviteFormSection = page.locator("form").locator("..");
120+
// Wait for the invite link section to appear below the form
124121
await expect(
125-
inviteFormSection.getByRole("button", { name: "Copy invite link" })
122+
page.locator('[data-testid="invite-copy-link-btn"]')
126123
).toBeVisible({ timeout: 10_000 });
127124

128125
// The invite form triggers router.refresh() after insert. Wait for the
@@ -139,7 +136,7 @@ test.describe("Workspace member management", () => {
139136
await goToMembersPage(page, workspaceSlug);
140137

141138
// The pending invites section should show the invited email
142-
const pendingSection = page.locator("text=Pending invites").first();
139+
const pendingSection = page.locator('[data-testid="pending-invite-list"]');
143140
await expect(pendingSection).toBeVisible({ timeout: 10_000 });
144141

145142
// The invited email should appear in the pending invites table
@@ -178,15 +175,14 @@ test.describe("Workspace member management", () => {
178175

179176
// Re-invite the same email. Wait for the form to be interactive before
180177
// filling — the page is a server component that hydrates the client form.
181-
const emailInput = page.locator("#invite-email");
178+
const emailInput = page.locator('[data-testid="invite-email-input"]');
182179
await expect(emailInput).toBeVisible({ timeout: 5_000 });
183180
await emailInput.fill(INVITE_EMAIL);
184181
await page.getByRole("button", { name: "Invite", exact: true }).click();
185182

186-
// Wait for the invite link URL to appear below the form
187-
const reInviteFormSection = page.locator("form").locator("..");
183+
// Wait for the invite link section to appear below the form
188184
await expect(
189-
reInviteFormSection.getByRole("button", { name: "Copy invite link" })
185+
page.locator('[data-testid="invite-copy-link-btn"]')
190186
).toBeVisible({ timeout: 10_000 });
191187

192188
// Create the test user via admin API so they can accept the invite
@@ -290,18 +286,18 @@ test.describe("Workspace member management", () => {
290286
).toBeVisible({ timeout: 10_000 });
291287

292288
// Member should NOT see the invite form
293-
await expect(memberPage.locator("#invite-email")).not.toBeVisible();
289+
await expect(memberPage.locator('[data-testid="invite-form"]')).not.toBeVisible();
294290

295291
// Member should NOT see remove buttons (trash icons)
296292
const removeButtons = memberPage.getByRole("button", {
297293
name: /remove/i,
298294
});
299295
await expect(removeButtons).toHaveCount(0);
300296

301-
// Member should NOT see role select dropdowns in the members table
297+
// Member should NOT see role select dropdowns in the members list
302298
// (roles are shown as badges, not selects, for non-admin users)
303-
const membersTable = memberPage.getByRole("table").first();
304-
const roleSelects = membersTable.getByRole("combobox");
299+
const membersList = memberPage.locator('[data-testid="members-list"]');
300+
const roleSelects = membersList.getByRole("combobox");
305301
await expect(roleSelects).toHaveCount(0);
306302

307303
await memberPage.context().close();

src/components/members/invite-form.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export function InviteForm({
166166
<p className="text-xs tracking-widest uppercase text-label-faint">
167167
Invite
168168
</p>
169-
<form onSubmit={handleSubmit} className="flex items-end gap-2">
169+
<form onSubmit={handleSubmit} className="flex items-end gap-2" data-testid="invite-form">
170170
<div className="flex flex-1 flex-col gap-1.5">
171171
<Label htmlFor="invite-email">Email</Label>
172172
<Input
@@ -179,6 +179,7 @@ export function InviteForm({
179179
setInviteLink(null);
180180
}}
181181
required
182+
data-testid="invite-email-input"
182183
/>
183184
</div>
184185
<div className="flex flex-col gap-1.5">
@@ -187,22 +188,22 @@ export function InviteForm({
187188
value={role}
188189
onValueChange={(val) => setRole(val as InviteRole)}
189190
>
190-
<SelectTrigger size="sm" className="w-28" aria-label="Invite role">
191+
<SelectTrigger size="sm" className="w-28" aria-label="Invite role" data-testid="invite-role-select">
191192
<SelectValue />
192193
</SelectTrigger>
193194
<SelectContent>
194-
<SelectItem value="admin">admin</SelectItem>
195-
<SelectItem value="member">member</SelectItem>
195+
<SelectItem value="admin" data-testid="invite-role-option-admin">admin</SelectItem>
196+
<SelectItem value="member" data-testid="invite-role-option-member">member</SelectItem>
196197
</SelectContent>
197198
</Select>
198199
</div>
199-
<Button type="submit" size="sm" disabled={sending}>
200+
<Button type="submit" size="sm" disabled={sending} data-testid="invite-submit-btn">
200201
<Send className="h-4 w-4" />
201202
{sending ? "Sending…" : "Invite"}
202203
</Button>
203204
</form>
204205
{inviteLink && (
205-
<div className="flex items-center gap-2">
206+
<div className="flex items-center gap-2" data-testid="invite-link-section">
206207
<p className="min-w-0 flex-1 truncate text-xs text-accent">
207208
{inviteLink}
208209
</p>
@@ -211,6 +212,7 @@ export function InviteForm({
211212
size="icon-sm"
212213
onClick={handleCopyLink}
213214
aria-label="Copy invite link"
215+
data-testid="invite-copy-link-btn"
214216
>
215217
{linkCopied ? (
216218
<Check className="h-4 w-4 text-accent" />

src/components/members/member-list.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function MemberList({
9191
}
9292

9393
return (
94-
<div className="flex flex-col gap-3">
94+
<div className="flex flex-col gap-3" data-testid="members-list">
9595
<p className="text-xs tracking-widest uppercase text-label-faint">
9696
Members ({members.length})
9797
</p>
@@ -105,7 +105,7 @@ export function MemberList({
105105
</TableHeader>
106106
<TableBody>
107107
{members.map((member) => (
108-
<TableRow key={member.id}>
108+
<TableRow key={member.id} data-testid={`member-row-${member.user_id}`}>
109109
<TableCell>
110110
<div className="flex flex-col gap-0.5">
111111
<span className="text-sm font-medium">
@@ -127,9 +127,10 @@ export function MemberList({
127127
value={member.role}
128128
onChange={(role) => onRoleChange(member.id, role)}
129129
includeOwner={currentUserRole === "owner"}
130+
data-testid={`member-role-select-${member.user_id}`}
130131
/>
131132
) : (
132-
<Badge variant={roleBadgeVariant(member.role)}>
133+
<Badge variant={roleBadgeVariant(member.role)} data-testid={`member-role-badge-${member.user_id}`}>
133134
{member.role}
134135
</Badge>
135136
)}
@@ -149,6 +150,7 @@ export function MemberList({
149150
variant="ghost"
150151
size="icon-sm"
151152
aria-label={`Remove ${member.profiles.display_name}`}
153+
data-testid={`member-remove-btn-${member.user_id}`}
152154
/>
153155
}
154156
>

src/components/members/pending-invite-list.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function PendingInviteList({
8686
}
8787

8888
return (
89-
<div className="flex flex-col gap-3">
89+
<div className="flex flex-col gap-3" data-testid="pending-invite-list">
9090
<p className="text-xs tracking-widest uppercase text-label-faint">
9191
Pending invites ({invites.length})
9292
</p>
@@ -101,7 +101,7 @@ export function PendingInviteList({
101101
</TableHeader>
102102
<TableBody>
103103
{invites.map((invite) => (
104-
<TableRow key={invite.id}>
104+
<TableRow key={invite.id} data-testid={`pending-invite-row-${invite.id}`}>
105105
<TableCell>
106106
<span className="text-sm">{invite.email}</span>
107107
</TableCell>
@@ -127,6 +127,7 @@ export function PendingInviteList({
127127
onClick={() => handleRevoke(invite.id)}
128128
disabled={revokingId === invite.id}
129129
aria-label={`Revoke invite for ${invite.email}`}
130+
data-testid={`pending-invite-revoke-btn-${invite.id}`}
130131
>
131132
<X className="h-4 w-4 text-muted-foreground" />
132133
</Button>

src/components/members/role-select.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,27 @@ interface RoleSelectProps {
1313
value: MemberRole;
1414
onChange: (role: MemberRole) => void;
1515
includeOwner?: boolean;
16+
"data-testid"?: string;
1617
}
1718

1819
export function RoleSelect({
1920
value,
2021
onChange,
2122
includeOwner = false,
23+
"data-testid": testId,
2224
}: RoleSelectProps) {
2325
return (
2426
<Select
2527
value={value}
2628
onValueChange={(val) => onChange(val as MemberRole)}
2729
>
28-
<SelectTrigger size="sm" className="w-28" aria-label="Member role">
30+
<SelectTrigger size="sm" className="w-28" aria-label="Member role" data-testid={testId ?? "members-role-select-trigger"}>
2931
<SelectValue />
3032
</SelectTrigger>
3133
<SelectContent>
32-
{includeOwner && <SelectItem value="owner">owner</SelectItem>}
33-
<SelectItem value="admin">admin</SelectItem>
34-
<SelectItem value="member">member</SelectItem>
34+
{includeOwner && <SelectItem value="owner" data-testid="members-role-option-owner">owner</SelectItem>}
35+
<SelectItem value="admin" data-testid="members-role-option-admin">admin</SelectItem>
36+
<SelectItem value="member" data-testid="members-role-option-member">member</SelectItem>
3537
</SelectContent>
3638
</Select>
3739
);

0 commit comments

Comments
 (0)