diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts new file mode 100644 index 000000000..326dc10a8 --- /dev/null +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -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= + * + * 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. + */ + +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 { + 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.'); + } +} + +async function openCreateMenu(page: Page): Promise { + 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 { + 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); + }); + + // 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 }); + 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= + 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-`). + 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 }); + }); +}); diff --git a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html index fc1deb2c2..b53d28cd6 100644 --- a/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html +++ b/apps/lfx-one/src/app/layouts/main-layout/main-layout.component.html @@ -14,7 +14,7 @@ }">
- +
diff --git a/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.html b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.html new file mode 100644 index 000000000..a2154a9bc --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.html @@ -0,0 +1,40 @@ + + + +
+ +
+
+ +
+
+

{{ createLabel }}

+

{{ artifact.description }}

+
+
+ + +
+ + +
+ + +
+ + +
+
diff --git a/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts new file mode 100644 index 000000000..e7234ef5d --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts @@ -0,0 +1,161 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ButtonComponent } from '@components/button/button.component'; +import { ProjectSelectorComponent } from '@components/project-selector/project-selector.component'; +import { CREATABLE_ARTIFACTS } from '@lfx-one/shared/constants'; +import { CreatableArtifactConfig, CreatableArtifactType, CreatableProject, LensItem, ProjectContext } from '@lfx-one/shared/interfaces'; +import { CreatePermissionService } from '@services/create-permission.service'; +import { LensService } from '@services/lens.service'; +import { ProjectContextService } from '@services/project-context.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +@Component({ + selector: 'lfx-create-artifact-dialog', + imports: [ReactiveFormsModule, ProjectSelectorComponent, ButtonComponent], + templateUrl: './create-artifact-dialog.component.html', +}) +export class CreateArtifactDialogComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly config = inject(DynamicDialogConfig); + private readonly router = inject(Router); + private readonly projectContextService = inject(ProjectContextService); + private readonly createPermissionService = inject(CreatePermissionService); + private readonly lensService = inject(LensService); + private readonly messageService = inject(MessageService); + + // The artifact type is chosen in the rail popover and handed to the dialog as data; + // this dialog only resolves the project/foundation for that fixed type. + protected readonly artifact: CreatableArtifactConfig = this.resolveArtifact(); + + // Header + primary CTA copy, e.g. "Create Meeting". + protected readonly createLabel = `Create ${this.artifact.label}`; + + protected readonly form = new FormGroup({ + project: new FormControl(null, { validators: [Validators.required] }), + }); + + // The picked project — drives the selector's trigger label/checkmark. Set on itemSelected. + protected readonly selectedContext: WritableSignal = signal(null); + + // Reactive by reference: the rail button only renders once this list is non-empty, so the + // dialog always opens populated — but reading the signal keeps it correct if it widens + // (e.g. persona data resolving and unlocking another lens) while the dialog is open. + protected readonly projectOptions: Signal = this.createPermissionService.creatableProjects; + + // Writer-scoped options mapped to the selector's `LensItem` shape. Feeding this as the selector's + // curated `items` input keeps the list scoped to projects the user holds `writer` on — never the + // view-scoped NavigationService catalog the sidebar selector pulls by default. + protected readonly selectorItems: Signal = computed(() => + this.projectOptions().map((project) => ({ + uid: project.uid, + slug: project.slug, + name: project.name, + logoUrl: project.logoUrl ?? null, + isFoundation: project.isFoundation, + })) + ); + + public constructor() { + // Auto-select when the user can create on exactly one project/foundation: pre-fill the choice so the + // dialog opens with Continue enabled and the picker just shows the locked-in row — no redundant + // open-and-pick for a list of one. The rail button only renders once the list is non-empty, so this + // reflects the state the dialog opened in; if it later widens (persona data resolving), the user can + // still change the selection via the selector. + const options = this.projectOptions(); + if (options.length === 1) { + this.applySelection(options[0]); + } + } + + /** Bridge the selector's `itemSelected` output into the form control that gates the Continue CTA. */ + public onItemSelected(item: LensItem): void { + this.applySelection(this.projectOptions().find((option) => option.uid === item.uid) ?? null); + } + + public onContinue(): void { + const projectUid = this.form.controls.project.value; + if (!projectUid) { + return; + } + + // The options are a live signal, so the selection can disappear mid-dialog if the list + // narrows (e.g. a persona refresh dropping a lens) while the form control keeps the old uid. + const project = this.projectOptions().find((option) => option.uid === projectUid); + if (!project) { + this.failWith('That project is no longer available. Please choose another.'); + return; + } + + const context: ProjectContext = this.toContext(project); + + // `activeContext` is lens-gated: under `me`/`org` it reads the slot matching the user's + // *persona*, not the kind picked here, so the create page would resolve the other slot. + // Aligning the lens first makes it read the slot seeded below. `setLens` refuses a lens the + // persona doesn't hold — `writer` and lens roles are independent grants — and there is no way + // to align the context in that case, so bail before mutating anything rather than create + // against a stale project (writerGuard's ED fast-path would not catch it). + if (!this.lensService.setLens(project.isFoundation ? 'foundation' : 'project')) { + console.error('Cannot align lens to the selected project; aborting create navigation.', { + slug: project.slug, + isFoundation: project.isFoundation, + }); + this.failWith(`You don't have access to create in ${project.name}.`); + return; + } + + // Seed the slot matching the selection's kind. `syncUrl: false` because the default rewrites + // the *current* page's history entry via replaceState — the dialog is global to the rail, so + // that would re-point whatever page the user opened it from. `router.navigate` carries the + // slug to the destination instead. + if (project.isFoundation) { + this.projectContextService.setFoundation(context, false); + } else { + this.projectContextService.setProject(context, false); + } + + // The slug keeps writerGuard authoritative on the chosen project and lets + // projectQueryParamGuard re-seed real project data on the lens-prefixed mounts. + this.router.navigate([this.artifact.createRoute], { queryParams: { project: project.slug } }); + this.dialogRef.close(true); + } + + public cancel(): void { + this.dialogRef.close(false); + } + + /** Set (or clear) the current selection — drives the selector's display and the Continue-gating control. */ + private applySelection(project: CreatableProject | null): void { + this.selectedContext.set(project ? this.toContext(project) : null); + this.form.controls.project.setValue(project?.uid ?? null); + this.form.controls.project.markAsTouched(); + } + + /** Project a writer-scoped `CreatableProject` down to the `ProjectContext` the context service stores. */ + private toContext(project: CreatableProject): ProjectContext { + return { uid: project.uid, name: project.name, slug: project.slug, parent_uid: project.parent_uid, logoUrl: project.logoUrl }; + } + + /** Surface a dead-end to the user and close, rather than leaving the CTA silently inert. */ + private failWith(detail: string): void { + this.messageService.add({ severity: 'error', summary: `Cannot create ${this.artifact.label}`, detail }); + this.dialogRef.close(false); + } + + private resolveArtifact(): CreatableArtifactConfig { + const type = this.config.data?.type as CreatableArtifactType | undefined; + const artifact = CREATABLE_ARTIFACTS.find((candidate) => candidate.type === type); + if (!artifact) { + // Only reachable if a caller opens the dialog with a bad type — a programming error. The + // template needs a config, so fall back to the first, but say so loudly rather than let the + // user quietly land on a create flow they never asked for. + console.error('CreateArtifactDialogComponent opened without a valid artifact type.', { type }); + return CREATABLE_ARTIFACTS[0]; + } + return artifact; + } +} diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html index 5bc6b65fc..abc79e13d 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.html @@ -69,6 +69,25 @@ Linux Foundation + + @if (createPermissionService.canShowCreateButton()) { + + } + @if (showLensButtons()) { @@ -186,6 +205,32 @@
+ + + + @let creatables = creatableArtifacts(); +
+ @for (artifact of creatables; track artifact.type; let i = $index) { + + @if (i > 0 && artifact.group !== creatables[i - 1].group) { + + } + + } +
+
+
+ diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.scss b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.scss index 3b05edde3..167785855 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.scss +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.scss @@ -72,3 +72,56 @@ // stylelint-disable-next-line declaration-no-important margin-bottom: 0 !important; } + +// Create menu — same treatment as the LFX Apps menu (arrow hidden, no content +// padding, pinned beside the rail). The Create button sits near the top of the +// rail, so this panel is top-anchored and opens downward, mirroring how the +// bottom-anchored apps menu opens upward from its button. +// stylelint-disable-next-line selector-pseudo-element-no-unknown +::ng-deep .lfx-create-popover.p-popover { + // Hide arrow + &::before, + &::after { + display: none !important; + } + + // Remove default content padding (the menu supplies its own via `py-1`). + .p-popover-content { + @apply p-0; + } + + // PrimeNG sets position/left/top inline via absolutePosition(); !important is + // required to override. Fixed keeps the menu put while the page scrolls. + // stylelint-disable-next-line declaration-no-important + position: fixed !important; + + // Just to the right of the rail: 56px = 48px rail + 8px gap. + // stylelint-disable-next-line declaration-no-important + left: 56px !important; + + // Align the menu's top edge with the Create button's top edge: + // rail padding (18) + LF logo (24) + gap (≈16) ≈ 58px from the viewport top. + // Unlike the bottom-anchored apps/user menus, a top-anchored panel is not immune to the + // impersonation banner, which shifts the rail down by `top-12` (48px) — see the variant below. + // stylelint-disable-next-line declaration-no-important + top: 58px !important; + + // stylelint-disable-next-line declaration-no-important + bottom: auto !important; + + // stylelint-disable-next-line declaration-no-important + margin-top: 0 !important; +} + +// Applied when the host layout declares it shifts the rail down to clear a banner — `main-layout` +// under impersonation (`top-12`, 48px), taking the Create button with it. Driven by the +// `bannerOffset` input rather than the impersonation flag, because the shift is layout-local: the +// docs shell pins the rail at `top-0` and renders no banner even while impersonating. 58 + 48 = 106px. +// Both classes are stacked deliberately: matching only `.lfx-create-popover-banner-offset` ties +// the base rule on specificity, so this would win on source order alone and silently revert to +// 58px if either block moved. +// stylelint-disable-next-line selector-pseudo-element-no-unknown +::ng-deep .lfx-create-popover.lfx-create-popover-banner-offset.p-popover { + // stylelint-disable-next-line declaration-no-important + top: 106px !important; +} diff --git a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts index d6ebef142..495b56cdd 100644 --- a/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts +++ b/apps/lfx-one/src/app/shared/components/lens-switcher/lens-switcher.component.ts @@ -8,11 +8,14 @@ import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { AvatarComponent } from '@components/avatar/avatar.component'; import { ButtonComponent } from '@components/button/button.component'; import { ChangelogDrawerComponent } from '@components/changelog-drawer/changelog-drawer.component'; +import { CreateArtifactDialogComponent } from '@components/create-artifact-dialog/create-artifact-dialog.component'; import { ImpersonationDialogComponent } from '@components/impersonation-dialog/impersonation-dialog.component'; import { environment } from '@environments/environment'; -import { Lens } from '@lfx-one/shared/interfaces'; +import { CREATABLE_ARTIFACTS } from '@lfx-one/shared/constants'; +import { CreatableArtifactConfig, Lens } from '@lfx-one/shared/interfaces'; import { buildInsightsUrl, isDocsPath } from '@lfx-one/shared/utils'; import { ChangelogService } from '@services/changelog.service'; +import { CreatePermissionService } from '@services/create-permission.service'; import { LensService } from '@services/lens.service'; import { UserService } from '@services/user.service'; import { OpenIntercomDirective } from '@shared/directives/open-intercom.directive'; @@ -34,8 +37,16 @@ export class LensSwitcherComponent { private readonly userService = inject(UserService); private readonly dialogService = inject(DialogService); private readonly changelogService = inject(ChangelogService); + protected readonly createPermissionService = inject(CreatePermissionService); public readonly mobile = input(false); + /** + * Whether the host layout shifts the rail down to clear a banner. Only `main-layout` does + * (`top-12` under impersonation); the docs shell pins the rail at `top-0` and renders no + * banner. The Create popover is appended to body and top-anchored, so it can't infer the + * rail's offset — the layout that applies the shift has to declare it. + */ + public readonly bannerOffset = input(false); /** Render the vertical lens buttons in the rail. Every current caller (main layout desktop + mobile, docs shell) passes `false` since lenses live in the sidebar tabs; the `true` default is only a standalone-reuse fallback. */ public readonly showLensButtons = input(true); @@ -48,6 +59,12 @@ export class LensSwitcherComponent { protected readonly mentorshipUrl = environment.urls.mentorship; protected readonly userMenu = viewChild('userMenu'); protected readonly appsMenu = viewChild('appsMenu'); + protected readonly createMenu = viewChild('createMenu'); + + // Only offer artifact types the user can actually create somewhere (mirrors the rail button's visibility gate). + protected readonly creatableArtifacts = computed(() => + CREATABLE_ARTIFACTS.filter((artifact) => this.createPermissionService.creatableTypes().includes(artifact.type)) + ); /** * Tracks whether the active route is anywhere under `/docs/*` so the docs @@ -107,6 +124,30 @@ export class LensSwitcherComponent { }); } + protected toggleCreateMenu(event: Event): void { + this.createMenu()?.toggle(event); + } + + protected openCreateDialog(artifact: CreatableArtifactConfig): void { + this.createMenu()?.hide(); + this.dialogService.open(CreateArtifactDialogComponent, { + // No PrimeNG header — the dialog body renders its own "Create " header. + showHeader: false, + // Name the role="dialog" for assistive tech: with showHeader:false PrimeNG emits no + // generated title, so point ariaLabelledBy at the body's own

. + ariaLabelledBy: 'create-artifact-heading', + width: '480px', + // Uniform padding all around — PrimeNG's default content padding zeroes the top + // (normally supplied by the header we removed), so set it explicitly here. + contentStyle: { padding: '1.5rem' }, + modal: true, + draggable: false, + resizable: false, + dismissableMask: true, + data: { type: artifact.type }, + }); + } + protected openChangelogDrawer(): void { this.changelogDrawerVisible.set(true); } diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html index 642ebc2b3..d56367560 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.html @@ -43,7 +43,7 @@ #popover appendTo="body" [style]="{ width: '28rem' }" - [styleClass]="panelStyleClass" + [styleClass]="panelStyleClass()" (onShow)="onPopoverShow()" (onHide)="onPopoverHide()" data-testid="project-selector-panel"> diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.scss b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.scss index 15bb5dad3..649e6e4a3 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.scss +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.scss @@ -10,9 +10,12 @@ @apply font-medium leading-5 relative text-base text-gray-900 tracking-tight truncate w-full text-left; } -// PrimeNG Popover override — hide arrow, force right-of-sidebar positioning -// Same pattern as user-menu-popover in lens-switcher.component.scss -::ng-deep .project-selector-panel.p-popover { +// PrimeNG Popover cosmetics — hide arrow, radius, padding. Applies to both the sidebar panel +// and the curated (dialog) variant. Same pattern as user-menu-popover in lens-switcher.component.scss. +// stylelint-disable-next-line selector-pseudo-element-no-unknown +::ng-deep .project-selector-panel.p-popover, +// stylelint-disable-next-line selector-pseudo-element-no-unknown +::ng-deep .project-selector-panel-curated.p-popover { // Hide arrow &::before, &::after { @@ -31,7 +34,12 @@ // stylelint-disable-next-line declaration-no-important padding: 8px !important; } +} +// Sidebar-only fixed positioning (right of the rail + nav). NOT applied to the curated (dialog) +// variant, which lets PrimeNG anchor the popover to its own trigger inside the centered dialog. +// stylelint-disable-next-line selector-pseudo-element-no-unknown +::ng-deep .project-selector-panel.p-popover { // Desktop only — on mobile the sidebar is a drawer with a different layout; // scoping to lg+ avoids anchoring the panel off-screen on narrow viewports @media (min-width: 1024px) { @@ -44,6 +52,7 @@ position: fixed !important; // Position to the right of the full sidebar (48px rail + 300px nav = 348px) with an 8px gap. + // stylelint-disable-next-line declaration-no-important left: 356px !important; // `top` is set at runtime in onPopoverShow() to match the selector trigger's viewport diff --git a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts index 45643333b..ecc79fe9f 100644 --- a/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts +++ b/apps/lfx-one/src/app/shared/components/project-selector/project-selector.component.ts @@ -36,6 +36,21 @@ export class ProjectSelectorComponent { public readonly searchPlaceholder = input('Search...'); public readonly emptyMessage = input('No results found'); public readonly hybridMode = input(false); + /** + * Optional curated item list. When non-null, the selector renders THIS list with local + * search + tab filtering and no pagination, instead of pulling from `NavigationService`. + * The create-artifact dialog uses it to present a writer-scoped list in the familiar + * selector UI. When null (the default, and every sidebar caller), nav-backed behavior is + * unchanged. Intended to be paired with `hybridMode` so the All/Foundations/Projects tabs show. + * + * Divergence to note: role badges (`resolveRolePersona`) and All-tab parent nesting + * (`buildAllTabItems`) key off persona-detection data (`personaService.personaProjects()` / + * `detectedProjects()`), NOT the curated `items`. A curated entry that is writer-reachable but + * not persona-detected therefore renders without a role badge and flat (un-nested) in the All + * tab. It remains fully selectable — this is a cosmetic divergence from the nav-backed sidebar + * UX, accepted for the writer-scoped create list. + */ + public readonly items = input(null); public readonly itemSelected = output(); public readonly isPanelOpen = model(false); @@ -44,7 +59,14 @@ export class ProjectSelectorComponent { protected readonly selectorTabs: readonly SelectorTab[] = ['all', 'foundations', 'projects']; protected readonly searchControl = new FormControl('', { nonNullable: true }); - protected readonly panelStyleClass = 'project-selector-panel'; + /** True when a curated `items` list is supplied — switches sourcing/search/pagination to local mode. */ + protected readonly isCurated: Signal = computed(() => this.items() !== null); + /** Curated-mode search term (nav mode routes search through NavigationService instead). */ + private readonly localSearchTerm = signal(''); + + // Sidebar panel pins fixed to the right of the rail; the curated (dialog) variant drops that + // sidebar-only positioning so PrimeNG anchors the popover to its own trigger inside the dialog. + protected readonly panelStyleClass: Signal = computed(() => (this.isCurated() ? 'project-selector-panel-curated' : 'project-selector-panel')); protected readonly lensTypeLabel: Signal = this.initLensTypeLabel(); protected readonly displayName: Signal = this.initDisplayName(); protected readonly displayLogo: Signal = computed(() => this.selectedProject()?.logoUrl || ''); @@ -68,6 +90,10 @@ export class ProjectSelectorComponent { public constructor() { this.searchControl.valueChanges.pipe(takeUntilDestroyed()).subscribe((term) => { + if (this.isCurated()) { + this.localSearchTerm.set(term); + return; + } if (this.hybridMode()) { this.navigationService.setSearchTerm('foundation', term); this.navigationService.setSearchTerm('project', term); @@ -95,6 +121,10 @@ export class ProjectSelectorComponent { this.isPanelOpen.set(false); this.activeTab.set('all'); this.searchControl.setValue('', { emitEvent: false }); + if (this.isCurated()) { + this.localSearchTerm.set(''); + return; + } if (this.hybridMode()) { this.navigationService.setSearchTerm('foundation', ''); this.navigationService.setSearchTerm('project', ''); @@ -104,6 +134,10 @@ export class ProjectSelectorComponent { } protected loadMore(): void { + // Curated mode is fully loaded up-front — no pagination. + if (this.isCurated()) { + return; + } if (this.hybridMode()) { const tab = this.activeTab(); // All-tab drains foundations first so the higher-priority group completes before standalone projects appear. @@ -131,6 +165,11 @@ export class ProjectSelectorComponent { * positioning, so a viewport-relative top would mis-place the panel on scroll. */ private alignPanelTop(): void { + // Curated (dialog) mode positions the popover against its own trigger via PrimeNG defaults — + // the sidebar-relative top alignment (paired with the fixed left) would mis-place it in a dialog. + if (this.isCurated()) { + return; + } if (!isPlatformBrowser(this.platformId) || !window.matchMedia('(min-width: 1024px)').matches) { return; } @@ -150,6 +189,13 @@ export class ProjectSelectorComponent { if (detected) { return detected.isFoundation ? 'Foundation' : 'Project'; } + // Curated mode: the item's own `isFoundation` is authoritative for writer-reachable entries + // that aren't persona-detected — resolve it before falling back to the active lens, which + // would otherwise mislabel a foundation as "Project" (or vice versa) from the wrong page. + const curated = this.items()?.find((item) => item.uid === selectedUid); + if (curated) { + return curated.isFoundation ? 'Foundation' : 'Project'; + } } return this.lensService.activeLens() === 'foundation' ? 'Foundation' : 'Project'; } @@ -182,16 +228,39 @@ export class ProjectSelectorComponent { } private initFoundationItems(): Signal { - return computed(() => (this.hybridMode() ? this.navigationService.items('foundation')() : [])); + return computed(() => { + if (this.isCurated()) { + return this.curatedItems(true); + } + return this.hybridMode() ? this.navigationService.items('foundation')() : []; + }); } private initRawProjectItems(): Signal { - // NavigationService.applyVisibilityFilters already filters foundations from the project lens when the foundation lens is available; re-filtering here would hide rows project-only users are meant to select. - return computed(() => this.navigationService.items(this.hybridMode() ? 'project' : this.lens())()); + return computed(() => { + if (this.isCurated()) { + // Hybrid curated: non-foundations only (foundations come from foundationItems). + // Non-hybrid curated: the whole curated list is the single projects column. + return this.hybridMode() ? this.curatedItems(false) : this.curatedItems(null); + } + // NavigationService.applyVisibilityFilters already filters foundations from the project lens when the foundation lens is available; re-filtering here would hide rows project-only users are meant to select. + return this.navigationService.items(this.hybridMode() ? 'project' : this.lens())(); + }); + } + + /** Curated-mode source: the `items` input filtered by kind (null = any) and the local search term. */ + private curatedItems(foundation: boolean | null): LensItem[] { + const term = this.localSearchTerm().trim().toLowerCase(); + return (this.items() ?? []) + .filter((item) => foundation === null || item.isFoundation === foundation) + .filter((item) => !term || (item.name ?? '').toLowerCase().includes(term) || (item.slug ?? '').toLowerCase().includes(term)); } private initLoading(): Signal { return computed(() => { + if (this.isCurated()) { + return false; + } if (this.hybridMode()) { return this.navigationService.loading('foundation')() || this.navigationService.loading('project')(); } @@ -201,6 +270,9 @@ export class ProjectSelectorComponent { private initHasMore(): Signal { return computed(() => { + if (this.isCurated()) { + return false; + } if (this.hybridMode()) { const tab = this.activeTab(); if (tab === 'foundations') return this.navigationService.hasMore('foundation')(); diff --git a/apps/lfx-one/src/app/shared/services/create-permission.service.ts b/apps/lfx-one/src/app/shared/services/create-permission.service.ts new file mode 100644 index 000000000..20aa18cb7 --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/create-permission.service.ts @@ -0,0 +1,120 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { afterNextRender, computed, DestroyRef, inject, Injectable, Signal, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CREATABLE_ARTIFACTS } from '@lfx-one/shared/constants'; +import { CreatableArtifactType, CreatableProject, Project } from '@lfx-one/shared/interfaces'; +import { computeIsFoundation } from '@lfx-one/shared/utils'; +import { LensService } from '@services/lens.service'; +import { ProjectService } from '@services/project.service'; +import { map } from 'rxjs'; + +/** + * Decides whether the rail "Create" quick-link (and which artifact types) is + * offered to the current user, and which projects/foundations they may target. + * + * Eligibility is the intersection of two independent grants: + * - the project `writer` grant — the same signal `writerGuard` enforces. + * `GET /api/projects` batch access-checks every visible project and returns + * `writer` per project, so this reflects real per-project authorization + * rather than inferring it from a persona. + * - a lens the user's persona holds. `ProjectContextService.activeContext` is + * lens-gated, so the create page can only resolve a selection whose lens can + * be aligned. Offering a project outside the user's lenses would dead-end at + * "Create". These grants are independent — a pure ED holds no `project` lens + * yet may hold `writer` on subprojects — so both must hold. + * + * Everything resolves to `[]` while loading and on error, keeping the rail button + * hidden until eligibility is proven — fail closed. The error half of that relies on + * `ProjectService.getProjects()` catching internally and emitting `[]`; there is no + * local `catchError` because one on this stream would be unreachable. + * + * Granularity note: `writer` is not the whole story upstream. Meetings are broader + * (`meeting_coordinator` and committee-writer also qualify), so users holding only + * those roles are under-shown, as are EDs whose only writable projects fall outside + * their lenses. This is a UX affordance only — the create routes' `writerGuard` + * remains authoritative for non-ED personas, so the button never grants access, it + * only advertises it. A `GET /api/projects/creatable` endpoint encoding the per-type + * grants would make the list exact and let the create flows resolve their target + * without the lens, removing that constraint. + * + * Persona note (ED): gating is purely the FGA `writer` relation, with NO ED fast-path — + * unlike `writerGuard`, which synchronously allows the `executive-director` persona + * because an ED is not reliably granted a per-project `writer` relation. So an ED whose + * `GET /api/projects` returns no `writer: true` project sees no "Create" shortcut at all. + * This is an accepted trade-off (LFXV2-2721): the shortcut is deliberately a writer-scoped + * affordance, and EDs still create through the in-page flows, which carry their own ED + * fast-path. Reinstating the button for those EDs would mean sourcing their foundations + * from persona/lens data — the writer-scoped list is empty for them — which loosens the + * writer filter this feature is scoped to; out of scope here by design. + */ +@Injectable({ + providedIn: 'root', +}) +export class CreatePermissionService { + private readonly projectService = inject(ProjectService); + private readonly lensService = inject(LensService); + private readonly destroyRef = inject(DestroyRef); + + /** Projects the user holds `writer` on, before the lens intersection. */ + private readonly writerProjects = signal([]); + + /** + * Projects the user may create on — `writer` intersected with lens reach (see class doc). + * A `computed` rather than a filter applied at fetch time, so it re-evaluates when persona + * data resolves and widens the available lenses. + */ + public readonly creatableProjects: Signal = computed(() => { + const lenses = this.lensService.availableLenses(); + return this.writerProjects().filter((project) => lenses.some((lens) => lens.id === (project.isFoundation ? 'foundation' : 'project'))); + }); + + /** + * Artifact types offered to the user — all types when they can create anywhere, else none. + * + * All-or-nothing by design: a `writer` grant is not per-type, so every type is offered together. + * A new `CREATABLE_ARTIFACTS` entry therefore inherits visibility for free — it does NOT get + * independent gating. Do not assume adding a type here narrows who sees it. The per-type + * intersection this feeds (`lens-switcher`'s `creatableArtifacts` filter) is consequently a + * no-op today; it exists so the switcher stays correct if a future `GET /api/projects/creatable` + * endpoint makes this list a genuine per-type subset (see class doc). + */ + public readonly creatableTypes: Signal = computed(() => + this.creatableProjects().length > 0 ? CREATABLE_ARTIFACTS.map((artifact) => artifact.type) : [] + ); + + /** True when the user can create on at least one project. */ + public readonly canShowCreateButton: Signal = computed(() => this.creatableProjects().length > 0); + + public constructor() { + // Fetch after hydration, not at construction. Two reasons: the endpoint paginates every + // visible project and batch access-checks them, so a pending task would block SSR + // serialization on TTFB for every page; and `profile-affiliations` already issues this exact + // request during SSR, so the transfer cache would resolve it synchronously here and render + // the button on the client's first pass while the server rendered it absent — a hydration + // mismatch. `afterNextRender` runs browser-only, once the first render is committed. + afterNextRender(() => { + this.projectService + .getProjects() + .pipe( + map((projects) => projects.filter((project) => project.writer === true).map(toCreatableProject)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((projects) => this.writerProjects.set(projects)); + }); + } +} + +function toCreatableProject(project: Project): CreatableProject { + return { + uid: project.uid, + slug: project.slug, + name: project.name, + // Derived from the project's own attributes, so it stays correct regardless of the + // viewer's persona — it dispatches the selection to the foundation vs project slot. + isFoundation: computeIsFoundation(project), + parent_uid: project.parent_uid, + logoUrl: project.logo_url, + }; +} diff --git a/packages/shared/src/constants/create-artifact.constants.ts b/packages/shared/src/constants/create-artifact.constants.ts new file mode 100644 index 000000000..a62a6ec6f --- /dev/null +++ b/packages/shared/src/constants/create-artifact.constants.ts @@ -0,0 +1,66 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CreatableArtifactConfig } from '../interfaces/create-artifact.interface'; + +/** + * Type-selection entries for the rail "Create" quick-link menu + dialog. Array + * order is the render order, and the menu draws a separator wherever `group` + * changes between adjacent entries — so this list is the single source of truth + * for both sequence and grouping. + * + * Sequence is a semantic grouping, not usage frequency: + * - Engage: Meeting, Newsletter + * - Decide: Vote, Survey + * - Organize: Group, Mailing List + */ +export const CREATABLE_ARTIFACTS: CreatableArtifactConfig[] = [ + { + type: 'meeting', + label: 'Meeting', + description: 'Schedule a recurring or one-time meeting for a project.', + icon: 'fa-light fa-calendar', + createRoute: '/meetings/create', + group: 'engage', + }, + { + type: 'newsletter', + label: 'Newsletter', + description: 'Publish a newsletter to keep a project community informed.', + icon: 'fa-light fa-paper-plane', + createRoute: '/newsletters/create', + group: 'engage', + }, + { + type: 'vote', + label: 'Vote', + description: 'Open a vote to reach a decision with a project community.', + icon: 'fa-light fa-check-to-slot', + createRoute: '/votes/create', + group: 'decide', + }, + { + type: 'survey', + label: 'Survey', + description: 'Gather structured feedback from a project community.', + icon: 'fa-light fa-clipboard-list', + createRoute: '/surveys/create', + group: 'decide', + }, + { + type: 'group', + label: 'Group', + description: 'Create a group/committee to organize members around a project.', + icon: 'fa-light fa-people-group', + createRoute: '/groups/create', + group: 'organize', + }, + { + type: 'mailing-list', + label: 'Mailing List', + description: 'Set up a mailing list for project communications.', + icon: 'fa-light fa-envelope', + createRoute: '/mailing-lists/create', + group: 'organize', + }, +]; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 00badaa8f..2c57ad146 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -82,3 +82,4 @@ export * from './mktg-os-agents.constants'; export * from './project-context.constants'; export * from './project-staff.constants'; export * from './org-lens-project-detail.constants'; +export * from './create-artifact.constants'; diff --git a/packages/shared/src/interfaces/create-artifact.interface.ts b/packages/shared/src/interfaces/create-artifact.interface.ts new file mode 100644 index 000000000..3bfa77e7c --- /dev/null +++ b/packages/shared/src/interfaces/create-artifact.interface.ts @@ -0,0 +1,54 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { ProjectContext } from './project.interface'; + +/** + * Artifact types a permitted user can create from the rail "Create" quick-link. + * Values map 1:1 to the existing creation routes (see `CREATABLE_ARTIFACTS`). + */ +export type CreatableArtifactType = 'meeting' | 'newsletter' | 'vote' | 'survey' | 'group' | 'mailing-list'; + +/** + * Semantic grouping for the quick-link menu — the ordering rationale, not usage + * frequency: Engage (Meeting, Newsletter), Decide (Vote, Survey), Organize + * (Group, Mailing List). The popover draws a thin separator wherever consecutive + * entries change group, so the grouping reads as deliberate. No group labels are + * rendered yet — the field exists to drive separators and to anchor future labels. + */ +export type CreatableArtifactGroup = 'engage' | 'decide' | 'organize'; + +/** + * Static, non-permission config for a creatable artifact type — drives the + * type-selection rows in the rail "Create" popover, and the header of the + * project-selection dialog that follows. + */ +export interface CreatableArtifactConfig { + /** Stable identifier used for filtering, routing, and test ids. */ + type: CreatableArtifactType; + /** Row title, e.g. "Meeting". */ + label: string; + /** Supporting copy describing what the type is for. */ + description: string; + /** Font Awesome icon class, e.g. 'fa-light fa-calendar'. */ + icon: string; + /** Absolute route to the existing creation flow, e.g. '/meetings/create'. */ + createRoute: string; + /** Semantic group this entry belongs to; a change between adjacent entries draws a menu separator. */ + group: CreatableArtifactGroup; +} + +/** + * A project/foundation the current user is permitted to create a given artifact + * type on. Extends `ProjectContext` so it can be passed straight to + * `ProjectContextService.setProject()` / `setFoundation()` before navigation. + */ +export interface CreatableProject extends ProjectContext { + /** + * View-only, not part of any API payload: computed client-side by `computeIsFoundation()` + * from the project's own attributes. True when this context is a top-level foundation, + * which dispatches the selection to `setFoundation()` vs `setProject()` and selects the + * lens to align before navigating. + */ + isFoundation: boolean; +} diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index 455d5bf5c..5c6d4470c 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -266,3 +266,6 @@ export * from './donut-chart.interface'; // Entity project context interfaces export * from './entity-project-context.interface'; + +// Create artifact quick-link interfaces (rail "Create" button + dialog) +export * from './create-artifact.interface';