Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
2a289c5
feat(shortcuts): global New shortcut for meeting, group, mailing list…
manishdixitlfx Jul 16, 2026
9ae3207
feat(shortcuts): add newsletter to create quick-link
manishdixitlfx Jul 17, 2026
151d8c1
feat(shortcuts): expand quick-create to six types with grouped ordering
manishdixitlfx Jul 17, 2026
3fe5c67
fix(review): clarify creatableTypes all-or-nothing gating
manishdixitlfx Jul 17, 2026
7327b7e
fix(shortcuts): use project-selector in create dialog, keep writer-sc…
manishdixitlfx Jul 17, 2026
5a8cf7e
fix(review): scope create-dialog selector e2e to dialog, drop magic n…
manishdixitlfx Jul 17, 2026
37977f1
fix(review): correct S4 coverage-map docstring to match assertions
manishdixitlfx Jul 17, 2026
feda526
fix(review): reorder dialog signals, document ED writer-scope trade-off
manishdixitlfx Jul 17, 2026
547826c
feat(shortcuts): auto-select the sole eligible project in create dialog
manishdixitlfx Jul 17, 2026
f87b13b
fix(review): drop S3 on-open disabled assertion invalidated by auto-s…
manishdixitlfx Jul 17, 2026
18f0aeb
refactor(shortcuts): extract toContext helper in create dialog
manishdixitlfx Jul 17, 2026
ec90b29
fix(review): address PR #1121 review feedback
manishdixitlfx Jul 17, 2026
79be8aa
fix(review): resolve curated-mode kind label from items in project-se…
manishdixitlfx Jul 17, 2026
5ca3c64
fix(review): dialog selector panel positioning + tighten S6 lens-pref…
manishdixitlfx Jul 17, 2026
8853ae6
Merge branch 'main' into feat/LFXV2-2721-create-shortcuts
audigregorie Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions apps/lfx-one/e2e/create-quick-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

/**
* Create Quick-Link E2E — smoke set.
*
* Exercises the rail "Create" button → type popover → project-selection dialog.
* Visibility is driven by CreatePermissionService, which derives create
* capability from the project `writer` grant returned by GET /api/projects. The
* eligible projects are therefore the authenticated test user's real grants, so
* these tests assert structure/behavior rather than specific project names, and
* skip entirely when the user has no create permission (the button is hidden).
*
* Coverage map:
* - S1: rail "Create" button renders for a create-capable user
* - S2: clicking it opens a popover listing the six artifact types, in grouped order, with descriptions
* - S3: picking a type opens the dialog (header + project selector) with Continue disabled,
* and choosing an eligible project via the selector enables Continue
* - S4: the dialog's project selector reuses the sidebar pattern (search + All/Foundations/Projects
* tabs) and renders a selectable list. Writer-scoping of that list is guaranteed by the dialog
* feeding the curated `creatableProjects` (verified in production-code review), not asserted here.
* - S5: a single eligible project is auto-selected on open (Continue enabled without a pick); with
* multiple eligible projects nothing is pre-selected and Continue stays gated until the user picks
* - S6: Continue routes into the create flow — lands on the lens-prefixed create URL carrying ?project=<slug>
*
* Prerequisites:
* - Dev server reachable at the Playwright baseURL
* - `apps/lfx-one/.env` populated with TEST_USERNAME / TEST_PASSWORD (see global-setup.ts)
* - The test user must hold `writer` on at least one project for S1–S3 to run;
* otherwise the suite skips (no create permission → no button, by design).
*
* Note: this suite stops at the dialog boundary. It does not assert the post-Continue
* create page — that path is enforced by each route's writerGuard.
Comment thread
audigregorie marked this conversation as resolved.
Comment on lines +32 to +33
*/

import { expect, Locator, Page, test } from '@playwright/test';

const APP_HOME = '/';
const RAIL_TIMEOUT = 30_000;

test.setTimeout(120_000);

// Hard skip when the auth-bootstrap failed — mirror org-selector.spec.ts so CI triage
// isn't sent chasing a regression that's really a credentials issue.
function skipWhenAuthMissing(page: Page): void {
try {
const { hostname } = new URL(page.url());
if (hostname === 'auth0.com' || hostname.endsWith('.auth0.com')) {
test.skip(true, 'TEST_USERNAME / TEST_PASSWORD not configured — see global-setup.ts');
}
} catch {
// Malformed URL — keep running; a failure here is useful signal, not noise.
}
}

// Skip when the test user has no create permission — the button is intentionally absent.
async function skipWhenNoCreatePermission(page: Page): Promise<void> {
const trigger = page.getByTestId('create-rail-button');
const visible = await trigger.isVisible().catch(() => false);
if (!visible) {
test.skip(true, 'Test user holds `writer` on no project — button hidden by design.');
Comment on lines +57 to +61

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — because the suite skips when the test account holds no writer grant, a render/permission/eligibility regression could pass silently. Making the positive path non-skippable needs a page.route stub of /api/projects (a deliberate shift from this suite's real-API, name-agnostic design) plus a separate fail-closed case. Tracking as a follow-up rather than expanding this PR — leaving this thread open.

}
Comment on lines +56 to +62

@coderabbitai coderabbitai Bot Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Derive expected permissions independently from the UI under test.

The suite currently has no entitlement oracle, allowing regressions to be skipped, valid partial eligibility to fail, and writer-scope leaks to pass.

  • apps/lfx-one/e2e/create-quick-link.spec.ts#L55-L61: determine whether the user has writer eligibility from the projects response or a deterministic fixture, not button visibility.
  • apps/lfx-one/e2e/create-quick-link.spec.ts#L98-L114: derive the expected artifact types from eligible lenses rather than always expecting six.
  • apps/lfx-one/e2e/create-quick-link.spec.ts#L138-L158: compare rendered projects with the independently derived writer-eligible project set.
📍 Affects 1 file
  • apps/lfx-one/e2e/create-quick-link.spec.ts#L55-L61 (this comment)
  • apps/lfx-one/e2e/create-quick-link.spec.ts#L98-L114
  • apps/lfx-one/e2e/create-quick-link.spec.ts#L138-L158
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/e2e/create-quick-link.spec.ts` around lines 55 - 61, Replace
skipWhenNoCreatePermission’s UI-based entitlement check with writer eligibility
derived independently from the projects response or a deterministic fixture,
skipping only when that source confirms no eligible projects. In
apps/lfx-one/e2e/create-quick-link.spec.ts lines 98-114, derive expected
artifact types from the eligible lenses instead of assuming six; in lines
138-158, compare rendered projects against the independently derived
writer-eligible project set. Update all three sites while preserving valid
partial-eligibility assertions and preventing writer-scope leaks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — an independent entitlement oracle (stub /api/projects, derive expected types from eligible lenses, compare rendered vs writer-eligible set) is a real improvement, but a heavier rework that flips this suite from real-API to stubbed. Tracking as a follow-up — leaving open.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manishdixitlfx Understood—keeping this review open. Would you like me to create and assign a GitHub follow-up issue for the independent entitlement-oracle coverage?

Comment on lines +57 to +62

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the entitlement-oracle thread above — making the positive path non-skippable needs a page.route stub of /api/projects (a deliberate shift from this suite's real-API, name-agnostic design) plus a fail-closed case. Tracked as a follow-up. Leaving open.

}

async function openCreateMenu(page: Page): Promise<void> {
const trigger = page.getByTestId('create-rail-button');
await expect(trigger).toBeVisible({ timeout: RAIL_TIMEOUT });
await trigger.click();
await expect(page.getByTestId('create-menu')).toBeVisible({ timeout: 5_000 });
}

async function openDialogForType(page: Page, type: 'meeting' | 'newsletter' | 'vote' | 'survey' | 'group' | 'mailing-list'): Promise<void> {
await openCreateMenu(page);
await page.getByTestId(`create-menu-option-${type}`).click();
await expect(page.getByTestId('create-artifact-dialog')).toBeVisible({ timeout: 5_000 });
}

function continueButton(page: Page): Locator {
return page.getByTestId('create-artifact-continue-button').locator('button');
}

test.describe('Create Quick-Link — rail popover + dialog smoke set', () => {
test.beforeEach(async ({ page }) => {
await page.goto(APP_HOME, { waitUntil: 'domcontentloaded' });
skipWhenAuthMissing(page);
// Give writer-driven visibility a moment to resolve before gating.
await page
.getByTestId('create-rail-button')
.waitFor({ state: 'visible', timeout: RAIL_TIMEOUT })
.catch(() => undefined);
await skipWhenNoCreatePermission(page);
Comment on lines +87 to +91

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — because the suite skips when the test account holds no writer grant, a render/permission/eligibility regression could pass silently. Making the positive path non-skippable needs a page.route stub of /api/projects (a deliberate shift from this suite's real-API, name-agnostic design) plus a separate fail-closed case. Tracking as a follow-up rather than expanding this PR — leaving this thread open.

});

// S1 — rail button renders for a create-capable user
test('S1: the rail "Create" button is visible for a create-capable user', async ({ page }) => {
await expect(page.getByTestId('create-rail-button')).toBeVisible({ timeout: RAIL_TIMEOUT });
});

// S2 — the button opens a popover listing all six types, in the grouped sequence
test('S2: clicking the button opens a popover with the six artifact types in grouped order', async ({ page }) => {
await openCreateMenu(page);

// Grouped sequence: Engage (meeting, newsletter) | Decide (vote, survey) | Organize (group, mailing-list).
const expectedOrder = ['meeting', 'newsletter', 'vote', 'survey', 'group', 'mailing-list'];

for (const type of expectedOrder) {
await expect(page.getByTestId(`create-menu-option-${type}`)).toBeVisible();
}

// Assert render order matches the constant order, not just presence.
const renderedOrder = await page
.getByTestId('create-menu')
.locator('[data-testid^="create-menu-option-"]')
.evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-testid')?.replace('create-menu-option-', '')));
expect(renderedOrder).toEqual(expectedOrder);

await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting');
});

// S3 — picking a project via the selector enables Continue. (On-open enabled/disabled state is S5's
// job; asserting "disabled on open" here would be wrong for a single-eligible-project account, where
// the dialog auto-selects and Continue is already enabled.)
test('S3: picking "Meeting" opens the dialog and choosing a project enables Continue', async ({ page }) => {
await openDialogForType(page, 'meeting');

// Open the reused project-selector (same UI as the sidebar). Scope the trigger to the dialog —
// the same `project-selector` testid is emitted by the sidebar's instance in project/foundation lens.
const dialog = page.getByTestId('create-artifact-dialog');
await dialog.getByTestId('project-selector').click();
const panel = page.getByTestId('project-selector-panel');
await expect(panel).toBeVisible({ timeout: 5_000 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E2E panel locator ambiguous

Medium Severity

The new create dialog reuses lfx-project-selector, so a second project-selector-panel node exists whenever the sidebar already shows the project picker. S3–S6 resolve the panel with a page-level getByTestId('project-selector-panel') while only scoping the trigger to the dialog. On /, the active lens often stays project or foundation from cookies, so Playwright can hit strict-mode violations or interact with the wrong panel.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8853ae6. Configure here.

const firstItem = panel.locator('[data-testid^="lens-item-"]').first();
await expect(firstItem).toBeVisible({ timeout: 5_000 });
await firstItem.click();

await expect(continueButton(page)).toBeEnabled();
});

// S4 — the project selector reuses the sidebar pattern (search + tabs) and renders the writer-scoped list
test('S4: the project selector reuses the search + tabs pattern and renders selectable projects', async ({ page }) => {
await openDialogForType(page, 'meeting');

// Scope the trigger to the dialog — the sidebar renders the same `project-selector` testid in project/foundation lens.
const dialog = page.getByTestId('create-artifact-dialog');
await dialog.getByTestId('project-selector').click();
const panel = page.getByTestId('project-selector-panel');
await expect(panel).toBeVisible({ timeout: 5_000 });

// Familiar sidebar pattern: search input + All/Foundations/Projects tabs.
await expect(panel.getByTestId('project-search-input')).toBeVisible();
await expect(panel.getByRole('button', { name: 'All', exact: true })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Foundations', exact: true })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Projects', exact: true })).toBeVisible();

// The list is the dialog's writer-scoped `creatableProjects` (fed via the selector's curated `items`
// input), never the view-scoped nav catalog. Assert on the selector's contract — a non-empty set of
// selectable lens items — rather than hardcoding prod catalog names: this suite is real-API and
// name-agnostic (see file docstring + testing-best-practices "assert on shape, not fixtures").
await expect(panel.locator('[data-testid^="lens-item-"]').first()).toBeVisible({ timeout: 5_000 });
});

// S5 — auto-select single: a lone eligible project is pre-selected so Continue is enabled without a pick
test('S5: a single eligible project is auto-selected; multiple require an explicit pick', async ({ page }) => {
await openDialogForType(page, 'meeting');
const dialog = page.getByTestId('create-artifact-dialog');
const trigger = dialog.getByTestId('project-selector');

// Count eligible options, then toggle the panel closed via the trigger (Escape could close the dialog).
await trigger.click();
const panel = page.getByTestId('project-selector-panel');
await expect(panel).toBeVisible({ timeout: 5_000 });
const itemCount = await panel.locator('[data-testid^="lens-item-"]').count();
await trigger.click();
await expect(panel).toBeHidden();

if (itemCount === 1) {
// Auto-selected on open — no manual pick needed.
await expect(continueButton(page)).toBeEnabled();
} else {
// Multiple options: nothing pre-selected, Continue gated until the user picks.
await expect(continueButton(page)).toBeDisabled();
}
});

// S6 — Continue exercises the create-navigation path: lands on the lens-prefixed create URL carrying ?project=<slug>
test('S6: Continue navigates to the create page carrying the selected project slug', async ({ page }) => {
await openDialogForType(page, 'meeting');
const dialog = page.getByTestId('create-artifact-dialog');
await dialog.getByTestId('project-selector').click();
const panel = page.getByTestId('project-selector-panel');
await expect(panel).toBeVisible({ timeout: 5_000 });

// Capture the picked project's slug from its data-testid (`lens-item-<slug>`).
const firstItem = panel.locator('[data-testid^="lens-item-"]').first();
await expect(firstItem).toBeVisible({ timeout: 5_000 });
const slug = (await firstItem.getAttribute('data-testid'))?.replace('lens-item-', '') ?? '';
expect(slug).not.toBe('');
await firstItem.click();

await continueButton(page).click();

// onContinue aligns the lens then navigates; lensRedirectGuard forwards to the lens-prefixed mount,
// preserving ?project=. Require the lens prefix explicitly (foundation|project) — a bare
// /meetings/create would mean setLens/lensRedirectGuard didn't run, so it must NOT match.
await expect(page).toHaveURL(new RegExp(`/(foundation|project)/meetings/create\\?.*project=${slug}`), { timeout: 15_000 });
Comment thread
audigregorie marked this conversation as resolved.
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}">
<!-- Lens Switcher Column (48px) -->
<div class="w-[48px] flex-shrink-0" data-testid="lens-switcher-column">
<lfx-lens-switcher [showLensButtons]="false"></lfx-lens-switcher>
<lfx-lens-switcher [showLensButtons]="false" [bannerOffset]="userService.impersonating()"></lfx-lens-switcher>
</div>
<!-- Nav Panel (300px) -->
<div class="w-[300px] flex-shrink-0">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<form [formGroup]="form" (ngSubmit)="onContinue()" class="flex flex-col gap-4" data-testid="create-artifact-dialog">
<!-- Header: neutral type icon + "Create <Type>" + description -->
<div class="flex items-start gap-3" data-testid="create-artifact-summary">
<div class="w-10 h-10 rounded-lg flex-shrink-0 flex items-center justify-center bg-gray-100 text-gray-500">
<i [class]="artifact.icon + ' text-xl'"></i>
</div>
<div class="flex flex-col">
<h2 id="create-artifact-heading" class="text-lg font-semibold text-gray-900">{{ createLabel }}</h2>
<p class="text-xs text-gray-500">{{ artifact.description }}</p>
</div>
</div>

<!-- Foundation / project — reuses the sidebar project-selector (search + All/Foundations/Projects tabs,
logos, role badges). Fed the writer-scoped `selectorItems` via the curated `items` input so the list
is only projects the user holds `writer` on, not the view-scoped nav catalog. -->
<div class="flex flex-col gap-1">
<label class="text-sm text-gray-900">
Select Foundation/Project
<span class="text-red-500">*</span>
</label>
<lfx-project-selector
[lens]="'project'"
[hybridMode]="true"
[items]="selectorItems()"
Comment thread
manishdixitlfx marked this conversation as resolved.
[selectedProject]="selectedContext()"
searchPlaceholder="Search foundations and projects..."
emptyMessage="No foundations or projects available"
(itemSelected)="onItemSelected($event)"
data-testid="create-artifact-project-select" />
</div>

<!-- Actions -->
<div class="flex justify-end gap-3" data-testid="create-artifact-modal-actions">
<lfx-button label="Cancel" severity="secondary" [text]="true" size="small" type="button" (onClick)="cancel()" data-testid="create-artifact-cancel-button" />
<lfx-button [label]="createLabel" [disabled]="form.invalid" size="small" type="submit" data-testid="create-artifact-continue-button" />
</div>
</form>
Loading
Loading