Skip to content

Commit 3479125

Browse files
feat(shortcuts): global New create shortcut (LFXV2-2721) (#1121)
Global rail 'New' create shortcut: six grouped types (Meeting/Newsletter/Vote/Survey/Group/Mailing List), writer-scoped project picker reusing lfx-project-selector, single-project auto-select. Fail-closed eligibility (writer grant intersect lens). ED writer-scope trade-off and 4 architectural follow-ups tracked for a follow-up PR.
1 parent 52c07e8 commit 3479125

15 files changed

Lines changed: 882 additions & 10 deletions
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
/**
5+
* Create Quick-Link E2E — smoke set.
6+
*
7+
* Exercises the rail "Create" button → type popover → project-selection dialog.
8+
* Visibility is driven by CreatePermissionService, which derives create
9+
* capability from the project `writer` grant returned by GET /api/projects. The
10+
* eligible projects are therefore the authenticated test user's real grants, so
11+
* these tests assert structure/behavior rather than specific project names, and
12+
* skip entirely when the user has no create permission (the button is hidden).
13+
*
14+
* Coverage map:
15+
* - S1: rail "Create" button renders for a create-capable user
16+
* - S2: clicking it opens a popover listing the six artifact types, in grouped order, with descriptions
17+
* - S3: picking a type opens the dialog (header + project selector) with Continue disabled,
18+
* and choosing an eligible project via the selector enables Continue
19+
* - S4: the dialog's project selector reuses the sidebar pattern (search + All/Foundations/Projects
20+
* tabs) and renders a selectable list. Writer-scoping of that list is guaranteed by the dialog
21+
* feeding the curated `creatableProjects` (verified in production-code review), not asserted here.
22+
* - S5: a single eligible project is auto-selected on open (Continue enabled without a pick); with
23+
* multiple eligible projects nothing is pre-selected and Continue stays gated until the user picks
24+
* - S6: Continue routes into the create flow — lands on the lens-prefixed create URL carrying ?project=<slug>
25+
*
26+
* Prerequisites:
27+
* - Dev server reachable at the Playwright baseURL
28+
* - `apps/lfx-one/.env` populated with TEST_USERNAME / TEST_PASSWORD (see global-setup.ts)
29+
* - The test user must hold `writer` on at least one project for S1–S3 to run;
30+
* otherwise the suite skips (no create permission → no button, by design).
31+
*
32+
* Note: this suite stops at the dialog boundary. It does not assert the post-Continue
33+
* create page — that path is enforced by each route's writerGuard.
34+
*/
35+
36+
import { expect, Locator, Page, test } from '@playwright/test';
37+
38+
const APP_HOME = '/';
39+
const RAIL_TIMEOUT = 30_000;
40+
41+
test.setTimeout(120_000);
42+
43+
// Hard skip when the auth-bootstrap failed — mirror org-selector.spec.ts so CI triage
44+
// isn't sent chasing a regression that's really a credentials issue.
45+
function skipWhenAuthMissing(page: Page): void {
46+
try {
47+
const { hostname } = new URL(page.url());
48+
if (hostname === 'auth0.com' || hostname.endsWith('.auth0.com')) {
49+
test.skip(true, 'TEST_USERNAME / TEST_PASSWORD not configured — see global-setup.ts');
50+
}
51+
} catch {
52+
// Malformed URL — keep running; a failure here is useful signal, not noise.
53+
}
54+
}
55+
56+
// Skip when the test user has no create permission — the button is intentionally absent.
57+
async function skipWhenNoCreatePermission(page: Page): Promise<void> {
58+
const trigger = page.getByTestId('create-rail-button');
59+
const visible = await trigger.isVisible().catch(() => false);
60+
if (!visible) {
61+
test.skip(true, 'Test user holds `writer` on no project — button hidden by design.');
62+
}
63+
}
64+
65+
async function openCreateMenu(page: Page): Promise<void> {
66+
const trigger = page.getByTestId('create-rail-button');
67+
await expect(trigger).toBeVisible({ timeout: RAIL_TIMEOUT });
68+
await trigger.click();
69+
await expect(page.getByTestId('create-menu')).toBeVisible({ timeout: 5_000 });
70+
}
71+
72+
async function openDialogForType(page: Page, type: 'meeting' | 'newsletter' | 'vote' | 'survey' | 'group' | 'mailing-list'): Promise<void> {
73+
await openCreateMenu(page);
74+
await page.getByTestId(`create-menu-option-${type}`).click();
75+
await expect(page.getByTestId('create-artifact-dialog')).toBeVisible({ timeout: 5_000 });
76+
}
77+
78+
function continueButton(page: Page): Locator {
79+
return page.getByTestId('create-artifact-continue-button').locator('button');
80+
}
81+
82+
test.describe('Create Quick-Link — rail popover + dialog smoke set', () => {
83+
test.beforeEach(async ({ page }) => {
84+
await page.goto(APP_HOME, { waitUntil: 'domcontentloaded' });
85+
skipWhenAuthMissing(page);
86+
// Give writer-driven visibility a moment to resolve before gating.
87+
await page
88+
.getByTestId('create-rail-button')
89+
.waitFor({ state: 'visible', timeout: RAIL_TIMEOUT })
90+
.catch(() => undefined);
91+
await skipWhenNoCreatePermission(page);
92+
});
93+
94+
// S1 — rail button renders for a create-capable user
95+
test('S1: the rail "Create" button is visible for a create-capable user', async ({ page }) => {
96+
await expect(page.getByTestId('create-rail-button')).toBeVisible({ timeout: RAIL_TIMEOUT });
97+
});
98+
99+
// S2 — the button opens a popover listing all six types, in the grouped sequence
100+
test('S2: clicking the button opens a popover with the six artifact types in grouped order', async ({ page }) => {
101+
await openCreateMenu(page);
102+
103+
// Grouped sequence: Engage (meeting, newsletter) | Decide (vote, survey) | Organize (group, mailing-list).
104+
const expectedOrder = ['meeting', 'newsletter', 'vote', 'survey', 'group', 'mailing-list'];
105+
106+
for (const type of expectedOrder) {
107+
await expect(page.getByTestId(`create-menu-option-${type}`)).toBeVisible();
108+
}
109+
110+
// Assert render order matches the constant order, not just presence.
111+
const renderedOrder = await page
112+
.getByTestId('create-menu')
113+
.locator('[data-testid^="create-menu-option-"]')
114+
.evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-testid')?.replace('create-menu-option-', '')));
115+
expect(renderedOrder).toEqual(expectedOrder);
116+
117+
await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting');
118+
});
119+
120+
// S3 — picking a project via the selector enables Continue. (On-open enabled/disabled state is S5's
121+
// job; asserting "disabled on open" here would be wrong for a single-eligible-project account, where
122+
// the dialog auto-selects and Continue is already enabled.)
123+
test('S3: picking "Meeting" opens the dialog and choosing a project enables Continue', async ({ page }) => {
124+
await openDialogForType(page, 'meeting');
125+
126+
// Open the reused project-selector (same UI as the sidebar). Scope the trigger to the dialog —
127+
// the same `project-selector` testid is emitted by the sidebar's instance in project/foundation lens.
128+
const dialog = page.getByTestId('create-artifact-dialog');
129+
await dialog.getByTestId('project-selector').click();
130+
const panel = page.getByTestId('project-selector-panel');
131+
await expect(panel).toBeVisible({ timeout: 5_000 });
132+
const firstItem = panel.locator('[data-testid^="lens-item-"]').first();
133+
await expect(firstItem).toBeVisible({ timeout: 5_000 });
134+
await firstItem.click();
135+
136+
await expect(continueButton(page)).toBeEnabled();
137+
});
138+
139+
// S4 — the project selector reuses the sidebar pattern (search + tabs) and renders the writer-scoped list
140+
test('S4: the project selector reuses the search + tabs pattern and renders selectable projects', async ({ page }) => {
141+
await openDialogForType(page, 'meeting');
142+
143+
// Scope the trigger to the dialog — the sidebar renders the same `project-selector` testid in project/foundation lens.
144+
const dialog = page.getByTestId('create-artifact-dialog');
145+
await dialog.getByTestId('project-selector').click();
146+
const panel = page.getByTestId('project-selector-panel');
147+
await expect(panel).toBeVisible({ timeout: 5_000 });
148+
149+
// Familiar sidebar pattern: search input + All/Foundations/Projects tabs.
150+
await expect(panel.getByTestId('project-search-input')).toBeVisible();
151+
await expect(panel.getByRole('button', { name: 'All', exact: true })).toBeVisible();
152+
await expect(panel.getByRole('button', { name: 'Foundations', exact: true })).toBeVisible();
153+
await expect(panel.getByRole('button', { name: 'Projects', exact: true })).toBeVisible();
154+
155+
// The list is the dialog's writer-scoped `creatableProjects` (fed via the selector's curated `items`
156+
// input), never the view-scoped nav catalog. Assert on the selector's contract — a non-empty set of
157+
// selectable lens items — rather than hardcoding prod catalog names: this suite is real-API and
158+
// name-agnostic (see file docstring + testing-best-practices "assert on shape, not fixtures").
159+
await expect(panel.locator('[data-testid^="lens-item-"]').first()).toBeVisible({ timeout: 5_000 });
160+
});
161+
162+
// S5 — auto-select single: a lone eligible project is pre-selected so Continue is enabled without a pick
163+
test('S5: a single eligible project is auto-selected; multiple require an explicit pick', async ({ page }) => {
164+
await openDialogForType(page, 'meeting');
165+
const dialog = page.getByTestId('create-artifact-dialog');
166+
const trigger = dialog.getByTestId('project-selector');
167+
168+
// Count eligible options, then toggle the panel closed via the trigger (Escape could close the dialog).
169+
await trigger.click();
170+
const panel = page.getByTestId('project-selector-panel');
171+
await expect(panel).toBeVisible({ timeout: 5_000 });
172+
const itemCount = await panel.locator('[data-testid^="lens-item-"]').count();
173+
await trigger.click();
174+
await expect(panel).toBeHidden();
175+
176+
if (itemCount === 1) {
177+
// Auto-selected on open — no manual pick needed.
178+
await expect(continueButton(page)).toBeEnabled();
179+
} else {
180+
// Multiple options: nothing pre-selected, Continue gated until the user picks.
181+
await expect(continueButton(page)).toBeDisabled();
182+
}
183+
});
184+
185+
// S6 — Continue exercises the create-navigation path: lands on the lens-prefixed create URL carrying ?project=<slug>
186+
test('S6: Continue navigates to the create page carrying the selected project slug', async ({ page }) => {
187+
await openDialogForType(page, 'meeting');
188+
const dialog = page.getByTestId('create-artifact-dialog');
189+
await dialog.getByTestId('project-selector').click();
190+
const panel = page.getByTestId('project-selector-panel');
191+
await expect(panel).toBeVisible({ timeout: 5_000 });
192+
193+
// Capture the picked project's slug from its data-testid (`lens-item-<slug>`).
194+
const firstItem = panel.locator('[data-testid^="lens-item-"]').first();
195+
await expect(firstItem).toBeVisible({ timeout: 5_000 });
196+
const slug = (await firstItem.getAttribute('data-testid'))?.replace('lens-item-', '') ?? '';
197+
expect(slug).not.toBe('');
198+
await firstItem.click();
199+
200+
await continueButton(page).click();
201+
202+
// onContinue aligns the lens then navigates; lensRedirectGuard forwards to the lens-prefixed mount,
203+
// preserving ?project=. Require the lens prefix explicitly (foundation|project) — a bare
204+
// /meetings/create would mean setLens/lensRedirectGuard didn't run, so it must NOT match.
205+
await expect(page).toHaveURL(new RegExp(`/(foundation|project)/meetings/create\\?.*project=${slug}`), { timeout: 15_000 });
206+
});
207+
});

apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
}">
1515
<!-- Lens Switcher Column (48px) -->
1616
<div class="w-[48px] flex-shrink-0" data-testid="lens-switcher-column">
17-
<lfx-lens-switcher [showLensButtons]="false"></lfx-lens-switcher>
17+
<lfx-lens-switcher [showLensButtons]="false" [bannerOffset]="userService.impersonating()"></lfx-lens-switcher>
1818
</div>
1919
<!-- Nav Panel (300px) -->
2020
<div class="w-[300px] flex-shrink-0">
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<form [formGroup]="form" (ngSubmit)="onContinue()" class="flex flex-col gap-4" data-testid="create-artifact-dialog">
5+
<!-- Header: neutral type icon + "Create <Type>" + description -->
6+
<div class="flex items-start gap-3" data-testid="create-artifact-summary">
7+
<div class="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center bg-gray-100 text-gray-500">
8+
<i [class]="artifact.icon + ' text-xl'"></i>
9+
</div>
10+
<div class="flex flex-col">
11+
<h2 id="create-artifact-heading" class="text-lg font-semibold text-gray-900">{{ createLabel }}</h2>
12+
<p class="text-xs text-gray-500">{{ artifact.description }}</p>
13+
</div>
14+
</div>
15+
16+
<!-- Foundation / project — reuses the sidebar project-selector (search + All/Foundations/Projects tabs,
17+
logos, role badges). Fed the writer-scoped `selectorItems` via the curated `items` input so the list
18+
is only projects the user holds `writer` on, not the view-scoped nav catalog. -->
19+
<div class="flex flex-col gap-1">
20+
<label class="text-sm text-gray-900">
21+
Select Foundation/Project
22+
<span class="text-red-500">*</span>
23+
</label>
24+
<lfx-project-selector
25+
[lens]="'project'"
26+
[hybridMode]="true"
27+
[items]="selectorItems()"
28+
[selectedProject]="selectedContext()"
29+
searchPlaceholder="Search foundations and projects..."
30+
emptyMessage="No foundations or projects available"
31+
(itemSelected)="onItemSelected($event)"
32+
data-testid="create-artifact-project-select" />
33+
</div>
34+
35+
<!-- Actions -->
36+
<div class="flex justify-end gap-3" data-testid="create-artifact-modal-actions">
37+
<lfx-button label="Cancel" severity="secondary" [text]="true" size="small" type="button" (onClick)="cancel()" data-testid="create-artifact-cancel-button" />
38+
<lfx-button [label]="createLabel" [disabled]="form.invalid" size="small" type="submit" data-testid="create-artifact-continue-button" />
39+
</div>
40+
</form>

0 commit comments

Comments
 (0)