From 2a289c5a8f2764aeb128ab94058014c5203f888d Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Thu, 16 Jul 2026 16:19:07 -0700 Subject: [PATCH 01/14] feat(shortcuts): global New shortcut for meeting, group, mailing list (LFXV2-2721) Adds a "New" (+) button to the left nav rail that opens a popover to create a Meeting, Group, or Mailing List. A dialog picks the target Foundation/Project, then routes into the existing create flows with that context pre-selected. Eligibility is the intersection of the project writer grant, the same signal writerGuard enforces, and a lens the user's persona holds. GET /api/projects batch access-checks every visible project and returns writer per project. activeContext is lens-gated, so a project outside the user's lenses could not be resolved by the create page; writer and lens are independent grants, so both must hold. Everything resolves empty while loading and on error, keeping the button hidden until eligibility is proven. Applied on top of the attached patch: - Gate on the writer grant rather than a persona proxy. The patch inferred eligibility, and isFoundation, from which persona bucket a project arrived in, which mislabelled every ED-bucket project as a foundation and left parent_uid and logoUrl unset. isFoundation now comes from computeIsFoundation over the project's own attributes. - Align the lens before seeding context, and abort if setLens refuses, so the create page cannot resolve a different project than the one picked. - Pass syncUrl false, so seeding no longer rewrites the history entry of the page the dialog was opened from. - Navigate with ?project= so writerGuard is authoritative on the selection. - Load the project list in afterNextRender. The request is already issued during SSR by profile-affiliations, so the transfer cache would resolve it synchronously at construction and render the button on the client's first pass while the server rendered it absent. - Offset the popover under the impersonation banner, which shifts the rail down. The bottom-anchored apps and user menus are immune to this; a top-anchored one is not. - Report the dialog's dead ends with a toast instead of an inert CTA. - Target data-test for the project select in e2e; lfx-select does not emit data-testid, so the assertion could never run. - Use fa-light for the rail icon to match the other rail affordances. Co-authored-by: Nuno Eufrasio Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 120 +++++++++++++++++ .../main-layout/main-layout.component.html | 2 +- .../create-artifact-dialog.component.html | 47 +++++++ .../create-artifact-dialog.component.ts | 122 ++++++++++++++++++ .../lens-switcher.component.html | 38 ++++++ .../lens-switcher.component.scss | 51 ++++++++ .../lens-switcher/lens-switcher.component.ts | 40 +++++- .../services/create-permission.service.ts | 101 +++++++++++++++ .../constants/create-artifact.constants.ts | 32 +++++ packages/shared/src/constants/index.ts | 1 + .../interfaces/create-artifact.interface.ts | 43 ++++++ packages/shared/src/interfaces/index.ts | 3 + 12 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 apps/lfx-one/e2e/create-quick-link.spec.ts create mode 100644 apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.html create mode 100644 apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts create mode 100644 apps/lfx-one/src/app/shared/services/create-permission.service.ts create mode 100644 packages/shared/src/constants/create-artifact.constants.ts create mode 100644 packages/shared/src/interfaces/create-artifact.interface.ts 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..32caddcc4 --- /dev/null +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -0,0 +1,120 @@ +// 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 three artifact types with descriptions + * - S3: picking a type opens the dialog (header + project select) with Continue disabled, + * and choosing an eligible project enables Continue + * + * 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' | '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 three types with descriptions + test('S2: clicking the button opens a popover with the three artifact types', async ({ page }) => { + await openCreateMenu(page); + + await expect(page.getByTestId('create-menu-option-meeting')).toBeVisible(); + await expect(page.getByTestId('create-menu-option-group')).toBeVisible(); + await expect(page.getByTestId('create-menu-option-mailing-list')).toBeVisible(); + await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting'); + }); + + // S3 — picking a type opens the dialog; choosing an eligible project enables Continue + test('S3: picking "Meeting" opens the dialog and enabling Continue requires a project', async ({ page }) => { + await openDialogForType(page, 'meeting'); + + // Nothing chosen yet — Continue disabled. + await expect(continueButton(page)).toBeDisabled(); + + // The dropdown is populated from the user's real eligible projects; pick the first. + // `lfx-select` emits the id as `data-test` (not Playwright's default `data-testid`), so + // target the attribute the wrapper actually renders. + await page.locator('[data-test="create-artifact-project-select"]').click(); + const firstOption = page.getByRole('option').first(); + await expect(firstOption).toBeVisible({ timeout: 5_000 }); + await firstOption.click(); + + await expect(continueButton(page)).toBeEnabled(); + }); +}); 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..dbf84bbe5 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.html @@ -0,0 +1,47 @@ + + + +
+ +
+
+ +
+
+

{{ createLabel }}

+

{{ artifact.description }}

+
+
+ + +
+ + + @if (form.controls.project.errors?.['required'] && form.controls.project.touched) { + Please select a Foundation or a Project in order to create the {{ artifact.label }} + } +
+ + +
+ + +
+
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..d7477ce79 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts @@ -0,0 +1,122 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, inject, Signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ButtonComponent } from '@components/button/button.component'; +import { SelectComponent } from '@components/select/select.component'; +import { CREATABLE_ARTIFACTS } from '@lfx-one/shared/constants'; +import { CreatableArtifactConfig, CreatableArtifactType, CreatableProject, 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, SelectComponent, 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] }), + }); + + // 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; + + 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 = { + uid: project.uid, + name: project.name, + slug: project.slug, + parent_uid: project.parent_uid, + logoUrl: project.logoUrl, + }; + + // `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); + } + + /** 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..f1bb596b5 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,23 @@ Linux Foundation + + @if (createPermissionService.canShowCreateButton()) { + + } + @if (showLensButtons()) { @@ -186,6 +203,27 @@
+ + + +
+ @for (artifact of creatableArtifacts(); track artifact.type) { + + } +
+
+
+ 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..794fed32b 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,54 @@ // 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. +::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. +::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..ecdea4646 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,27 @@ 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, + 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/services/create-permission.service.ts b/apps/lfx-one/src/app/shared/services/create-permission.service.ts new file mode 100644 index 000000000..2b89dbb4e --- /dev/null +++ b/apps/lfx-one/src/app/shared/services/create-permission.service.ts @@ -0,0 +1,101 @@ +// 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. + */ +@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. */ + 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..a3a80f888 --- /dev/null +++ b/packages/shared/src/constants/create-artifact.constants.ts @@ -0,0 +1,32 @@ +// 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. Order + * here is the render order. + */ +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', + }, + { + type: 'group', + label: 'Group', + description: 'Create a group/committee to organize members around a project.', + icon: 'fa-light fa-people-group', + createRoute: '/groups/create', + }, + { + type: 'mailing-list', + label: 'Mailing List', + description: 'Set up a mailing list for project communications.', + icon: 'fa-light fa-envelope', + createRoute: '/mailing-lists/create', + }, +]; 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..c1e1d0b08 --- /dev/null +++ b/packages/shared/src/interfaces/create-artifact.interface.ts @@ -0,0 +1,43 @@ +// 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' | 'group' | 'mailing-list'; + +/** + * 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; +} + +/** + * 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'; From 9ae32079a9dbd4fa4d24734e44c6cb3c9b25ce78 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 08:37:25 -0700 Subject: [PATCH 02/14] feat(shortcuts): add newsletter to create quick-link Adds Newsletter as a fourth type in the rail Create quick-link. Newsletter creation is writer-gated by newsletterAccessGuard (ED or project.writer === true), the same signal CreatePermissionService already intersects on, so the entry never dead-ends an offered project. Uses the newsletters feature's own fa-paper-plane icon and the existing /newsletters/create route. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 9 +++++---- .../shared/src/constants/create-artifact.constants.ts | 7 +++++++ .../shared/src/interfaces/create-artifact.interface.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index 32caddcc4..22318fc32 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -13,7 +13,7 @@ * * Coverage map: * - S1: rail "Create" button renders for a create-capable user - * - S2: clicking it opens a popover listing the three artifact types with descriptions + * - S2: clicking it opens a popover listing the four artifact types with descriptions * - S3: picking a type opens the dialog (header + project select) with Continue disabled, * and choosing an eligible project enables Continue * @@ -63,7 +63,7 @@ async function openCreateMenu(page: Page): Promise { await expect(page.getByTestId('create-menu')).toBeVisible({ timeout: 5_000 }); } -async function openDialogForType(page: Page, type: 'meeting' | 'group' | 'mailing-list'): Promise { +async function openDialogForType(page: Page, type: 'meeting' | 'group' | 'mailing-list' | 'newsletter'): Promise { await openCreateMenu(page); await page.getByTestId(`create-menu-option-${type}`).click(); await expect(page.getByTestId('create-artifact-dialog')).toBeVisible({ timeout: 5_000 }); @@ -90,13 +90,14 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { await expect(page.getByTestId('create-rail-button')).toBeVisible({ timeout: RAIL_TIMEOUT }); }); - // S2 — the button opens a popover listing all three types with descriptions - test('S2: clicking the button opens a popover with the three artifact types', async ({ page }) => { + // S2 — the button opens a popover listing all four types with descriptions + test('S2: clicking the button opens a popover with the four artifact types', async ({ page }) => { await openCreateMenu(page); await expect(page.getByTestId('create-menu-option-meeting')).toBeVisible(); await expect(page.getByTestId('create-menu-option-group')).toBeVisible(); await expect(page.getByTestId('create-menu-option-mailing-list')).toBeVisible(); + await expect(page.getByTestId('create-menu-option-newsletter')).toBeVisible(); await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting'); }); diff --git a/packages/shared/src/constants/create-artifact.constants.ts b/packages/shared/src/constants/create-artifact.constants.ts index a3a80f888..a8e983dbd 100644 --- a/packages/shared/src/constants/create-artifact.constants.ts +++ b/packages/shared/src/constants/create-artifact.constants.ts @@ -29,4 +29,11 @@ export const CREATABLE_ARTIFACTS: CreatableArtifactConfig[] = [ icon: 'fa-light fa-envelope', createRoute: '/mailing-lists/create', }, + { + type: 'newsletter', + label: 'Newsletter', + description: 'Publish a newsletter to keep a project community informed.', + icon: 'fa-light fa-paper-plane', + createRoute: '/newsletters/create', + }, ]; diff --git a/packages/shared/src/interfaces/create-artifact.interface.ts b/packages/shared/src/interfaces/create-artifact.interface.ts index c1e1d0b08..14e5b45a8 100644 --- a/packages/shared/src/interfaces/create-artifact.interface.ts +++ b/packages/shared/src/interfaces/create-artifact.interface.ts @@ -7,7 +7,7 @@ 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' | 'group' | 'mailing-list'; +export type CreatableArtifactType = 'meeting' | 'group' | 'mailing-list' | 'newsletter'; /** * Static, non-permission config for a creatable artifact type — drives the From 151d8c1e7fd50967e6e2119d5ffcc54d579b6e67 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 08:40:47 -0700 Subject: [PATCH 03/14] feat(shortcuts): expand quick-create to six types with grouped ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the rail Create quick-link from four types to six, in a deliberate sequence: Meeting, Newsletter, Vote, Survey, Group, Mailing List. The order is a semantic grouping, not frequency — Engage (Meeting, Newsletter), Decide (Vote, Survey), Organize (Group, Mailing List). A thin separator is drawn wherever the group changes, driven by a new group field on CREATABLE_ARTIFACTS so sequence and grouping share one source of truth. No group labels yet. Vote and Survey create routes carry the same authGuard + writerGuard (writeFeature: votes/surveys) as the existing four, and CreatePermissionService already intersects on project.writer, so neither entry over-promises a route the persona cannot use. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 26 +++++++---- .../lens-switcher.component.html | 7 ++- .../constants/create-artifact.constants.ts | 45 +++++++++++++++---- .../interfaces/create-artifact.interface.ts | 13 +++++- 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index 22318fc32..4adcf3ddf 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -13,7 +13,7 @@ * * Coverage map: * - S1: rail "Create" button renders for a create-capable user - * - S2: clicking it opens a popover listing the four artifact types with descriptions + * - 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 select) with Continue disabled, * and choosing an eligible project enables Continue * @@ -63,7 +63,7 @@ async function openCreateMenu(page: Page): Promise { await expect(page.getByTestId('create-menu')).toBeVisible({ timeout: 5_000 }); } -async function openDialogForType(page: Page, type: 'meeting' | 'group' | 'mailing-list' | 'newsletter'): Promise { +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 }); @@ -90,14 +90,24 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { await expect(page.getByTestId('create-rail-button')).toBeVisible({ timeout: RAIL_TIMEOUT }); }); - // S2 — the button opens a popover listing all four types with descriptions - test('S2: clicking the button opens a popover with the four artifact types', async ({ page }) => { + // 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); - await expect(page.getByTestId('create-menu-option-meeting')).toBeVisible(); - await expect(page.getByTestId('create-menu-option-group')).toBeVisible(); - await expect(page.getByTestId('create-menu-option-mailing-list')).toBeVisible(); - await expect(page.getByTestId('create-menu-option-newsletter')).toBeVisible(); + // 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'); }); 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 f1bb596b5..640e3040f 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 @@ -206,8 +206,13 @@ + @let creatables = creatableArtifacts();
- @for (artifact of creatableArtifacts(); track artifact.type) { + @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/create-artifact-dialog/create-artifact-dialog.component.ts b/apps/lfx-one/src/app/shared/components/create-artifact-dialog/create-artifact-dialog.component.ts index d7477ce79..9b1ecbd36 100644 --- 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 @@ -1,13 +1,13 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, inject, Signal } from '@angular/core'; +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 { SelectComponent } from '@components/select/select.component'; +import { ProjectSelectorComponent } from '@components/project-selector/project-selector.component'; import { CREATABLE_ARTIFACTS } from '@lfx-one/shared/constants'; -import { CreatableArtifactConfig, CreatableArtifactType, CreatableProject, ProjectContext } from '@lfx-one/shared/interfaces'; +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'; @@ -16,7 +16,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; @Component({ selector: 'lfx-create-artifact-dialog', - imports: [ReactiveFormsModule, SelectComponent, ButtonComponent], + imports: [ReactiveFormsModule, ProjectSelectorComponent, ButtonComponent], templateUrl: './create-artifact-dialog.component.html', }) export class CreateArtifactDialogComponent { @@ -44,6 +44,32 @@ export class CreateArtifactDialogComponent { // (e.g. persona data resolving and unlocking another lens) while the dialog is open. protected readonly projectOptions: Signal = this.createPermissionService.creatableProjects; + // The picked project — drives the selector's trigger label/checkmark. Set on itemSelected. + protected readonly selectedContext: WritableSignal = signal(null); + + // 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, + })) + ); + + /** Bridge the selector's `itemSelected` output into the form control that gates the Continue CTA. */ + public onItemSelected(item: LensItem): void { + const project = this.projectOptions().find((option) => option.uid === item.uid); + this.selectedContext.set( + project ? { uid: project.uid, name: project.name, slug: project.slug, parent_uid: project.parent_uid, logoUrl: project.logoUrl } : null + ); + this.form.controls.project.setValue(item.uid); + this.form.controls.project.markAsTouched(); + } + public onContinue(): void { const projectUid = this.form.controls.project.value; if (!projectUid) { 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..e034d14e0 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,14 @@ 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. + */ + public readonly items = input(null); public readonly itemSelected = output(); public readonly isPanelOpen = model(false); @@ -44,6 +52,11 @@ export class ProjectSelectorComponent { protected readonly selectorTabs: readonly SelectorTab[] = ['all', 'foundations', 'projects']; protected readonly searchControl = new FormControl('', { nonNullable: true }); + /** 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(''); + protected readonly panelStyleClass = 'project-selector-panel'; protected readonly lensTypeLabel: Signal = this.initLensTypeLabel(); protected readonly displayName: Signal = this.initDisplayName(); @@ -68,6 +81,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 +112,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 +125,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. @@ -182,16 +207,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 +249,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')(); From 5a8cf7ebac875afcb44b8fa1784f3b334eb25d18 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 10:31:30 -0700 Subject: [PATCH 06/14] fix(review): scope create-dialog selector e2e to dialog, drop magic names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-commit trio flagged two e2e issues (production code was clean): - General reviewer: getByTestId('project-selector') was unscoped, but that testid is emitted by the lfx-project-selector template itself, so the sidebar's instance (present in project/foundation lens — the writer-scoped test user's normal state) also matches it, tripping Playwright strict mode. Scope the trigger to the dialog. - Conventions reviewer: S4 hardcoded catalog names ('AAA'/'QHTTPX') to assert a negative, which is vacuous and violates this suite's real-API, name-agnostic convention. Replace with a contract assertion (search + tabs + a selectable lens-item render); writer-scoping is guaranteed by the dialog feeding the curated writer-scoped list, verified in the production-code review. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 31 +++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index 24d8076dd..da574df45 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -120,34 +120,39 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { // Nothing chosen yet — Continue disabled. await expect(continueButton(page)).toBeDisabled(); - // Open the reused project-selector (same UI as the sidebar) and pick the first writer-scoped item. - await page.getByTestId('project-selector').click(); - await expect(page.getByTestId('project-selector-panel')).toBeVisible({ timeout: 5_000 }); - const firstItem = page.locator('[data-testid^="lens-item-"]').first(); + // 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 and is writer-scoped, not the global catalog - test('S4: the project selector shows search + tabs and excludes non-member catalog projects', async ({ page }) => { + // 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'); - await page.getByTestId('project-selector').click(); + // 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(page.getByTestId('project-search-input')).toBeVisible(); + 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(); - // Writer-scoped, not the view-scoped nav catalog: sentinel catalog entries the test user holds - // no writer role on must be absent. Passes as long as the user isn't a writer on these names. - for (const nonMember of ['AAA', 'QHTTPX']) { - await expect(panel.getByText(nonMember, { exact: true })).toHaveCount(0); - } + // 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 }); }); }); From 37977f1da3aea3d32208e82904a29b84d3428e5f Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 10:35:36 -0700 Subject: [PATCH 07/14] fix(review): correct S4 coverage-map docstring to match assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-commit trio (all three) flagged the S4 coverage-map line still claiming it lists 'only writer-scoped projects, not the global catalog' — the exact catalog- exclusion assertion the prior commit removed. Update the docstring to describe what S4 now verifies (search + tabs + a selectable list); writer-scoping is guaranteed by the dialog feeding the curated creatableProjects, verified in production-code review, not asserted in-suite. Comment-only. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index da574df45..c6821f4ec 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -17,7 +17,8 @@ * - 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 lists only writer-scoped projects, not the global catalog + * 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. * * Prerequisites: * - Dev server reachable at the Playwright baseURL From feda52623098799affc43213957358138660ef45 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 10:50:23 -0700 Subject: [PATCH 08/14] fix(review): reorder dialog signals, document ED writer-scope trade-off Full-branch sweep findings: - Conventions reviewer: selectedContext (WritableSignal) was declared after the derived projectOptions signal; component-organization.md wants writables before derived. Move selectedContext above the derived signals. No behavior change. - General reviewer: the Create button gates purely on the FGA writer relation with no ED fast-path, so an ED with no per-project writer grant sees no shortcut. Per product decision (LFXV2-2721) this stays writer-scoped; document the ED persona-differential explicitly in the service docstring as an accepted trade-off (EDs create via the in-page flows, which carry their own ED fast-path). Doc-only. Signed-off-by: Manish Dixit --- .../create-artifact-dialog.component.ts | 6 +++--- .../app/shared/services/create-permission.service.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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 index 9b1ecbd36..2a43de317 100644 --- 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 @@ -39,14 +39,14 @@ export class CreateArtifactDialogComponent { 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; - // The picked project — drives the selector's trigger label/checkmark. Set on itemSelected. - protected readonly selectedContext: WritableSignal = signal(null); - // 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. 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 index 9a4d67f4b..20aa18cb7 100644 --- a/apps/lfx-one/src/app/shared/services/create-permission.service.ts +++ b/apps/lfx-one/src/app/shared/services/create-permission.service.ts @@ -38,6 +38,16 @@ import { map } from 'rxjs'; * 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', From 547826c50f694a72a06a9871a2b1f36fdb1a94f9 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 11:22:29 -0700 Subject: [PATCH 09/14] feat(shortcuts): auto-select the sole eligible project in create dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user holds writer on exactly one project/foundation, the create dialog pre-selects it on open so Continue is enabled immediately and the picker shows the locked-in choice — no redundant open-and-pick for a list of one. Extracts the selection logic into applySelection(), called from the constructor when creatableProjects has a single entry and from onItemSelected otherwise. If the list later widens (persona data resolving), the user can still change the pick. Adds e2e S5, which adapts to the real-API test account: asserts Continue is enabled on open when exactly one project is eligible, and gated until a pick when several are. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 25 +++++++++++++++++ .../create-artifact-dialog.component.ts | 28 +++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index c6821f4ec..4986e324e 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -19,6 +19,8 @@ * - 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 * * Prerequisites: * - Dev server reachable at the Playwright baseURL @@ -156,4 +158,27 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { // 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(); + } + }); }); 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 index 2a43de317..edc02319c 100644 --- 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 @@ -60,14 +60,21 @@ export class CreateArtifactDialogComponent { })) ); + 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 { - const project = this.projectOptions().find((option) => option.uid === item.uid); - this.selectedContext.set( - project ? { uid: project.uid, name: project.name, slug: project.slug, parent_uid: project.parent_uid, logoUrl: project.logoUrl } : null - ); - this.form.controls.project.setValue(item.uid); - this.form.controls.project.markAsTouched(); + this.applySelection(this.projectOptions().find((option) => option.uid === item.uid) ?? null); } public onContinue(): void { @@ -127,6 +134,15 @@ export class CreateArtifactDialogComponent { 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 ? { uid: project.uid, name: project.name, slug: project.slug, parent_uid: project.parent_uid, logoUrl: project.logoUrl } : null + ); + this.form.controls.project.setValue(project?.uid ?? null); + this.form.controls.project.markAsTouched(); + } + /** 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 }); From f87b13b8b2362f71576359124e176a9ceb63ea56 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 11:26:42 -0700 Subject: [PATCH 10/14] fix(review): drop S3 on-open disabled assertion invalidated by auto-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit General reviewer flagged that S3 asserted Continue disabled immediately on dialog open, but the auto-select commit enables Continue on open for a single-eligible- project account — so S3 would fail where S5 passes on that account shape. Remove the on-open assertion (S5 now owns the enabled/disabled-on-open distinction); S3 keeps its real job: picking a project via the selector enables Continue. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index 4986e324e..fda9fb658 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -116,13 +116,12 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { await expect(page.getByTestId('create-menu-option-meeting')).toContainText('Schedule a recurring or one-time meeting'); }); - // S3 — picking a type opens the dialog; choosing an eligible project via the selector enables Continue + // 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'); - // Nothing chosen yet — Continue disabled. - await expect(continueButton(page)).toBeDisabled(); - // 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'); From 18f0aeb5042a622652f33e9da31b7876cda75dab Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 11:33:23 -0700 Subject: [PATCH 11/14] refactor(shortcuts): extract toContext helper in create dialog General reviewer (full-branch sweep) flagged the ProjectContext object being rebuilt field-by-field in both onContinue and applySelection. Extract a single toContext(project) helper so the field list lives in one place and can't drift if ProjectContext gains a field. No behavior change. Signed-off-by: Manish Dixit --- .../create-artifact-dialog.component.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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 index edc02319c..e7234ef5d 100644 --- 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 @@ -91,13 +91,7 @@ export class CreateArtifactDialogComponent { return; } - const context: ProjectContext = { - uid: project.uid, - name: project.name, - slug: project.slug, - parent_uid: project.parent_uid, - logoUrl: project.logoUrl, - }; + 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. @@ -136,13 +130,16 @@ export class CreateArtifactDialogComponent { /** 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 ? { uid: project.uid, name: project.name, slug: project.slug, parent_uid: project.parent_uid, logoUrl: project.logoUrl } : null - ); + 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 }); From ec90b2954a97b70ff9b40ef3fe762bdd419a9939 Mon Sep 17 00:00:00 2001 From: Manish Dixit Date: Fri, 17 Jul 2026 12:23:59 -0700 Subject: [PATCH 12/14] fix(review): address PR #1121 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from @audigregorie, copilot[bot], coderabbitai[bot]: - create-artifact dialog: name the role=dialog via ariaLabelledBy -> the body's

(showHeader:false emits no title) (per copilot[bot]) - lens-switcher.scss: suppress stylelint selector-pseudo-element-no-unknown on both ::ng-deep rules so CI stylelint passes (per coderabbitai[bot]) - lens-switcher.html: aria-label 'New' -> 'Create new artifact' (per @audigregorie) - project-selector items input: document the curated-mode divergence — role badges and All-tab nesting key off persona-detection, so writer-only items render flat/unbadged (per @audigregorie) - e2e S6: assert Continue routes to the lens-prefixed create URL carrying ?project= (per copilot[bot]) - lens-switcher.html: document the shortcut is desktop-rail only by design; mobile deferred (per copilot[bot]) Deferred to follow-up (responded on-thread, threads left open): entitlement-oracle e2e stub, once-only writer fetch retry/refresh, lens/context mutation-before-guard, and dynamic popover positioning. Resolves 10 of 16 review threads. Signed-off-by: Manish Dixit --- apps/lfx-one/e2e/create-quick-link.spec.ts | 23 +++++++++++++++++++ .../create-artifact-dialog.component.html | 2 +- .../lens-switcher.component.html | 6 +++-- .../lens-switcher.component.scss | 2 ++ .../lens-switcher/lens-switcher.component.ts | 3 +++ .../project-selector.component.ts | 7 ++++++ 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/lfx-one/e2e/create-quick-link.spec.ts b/apps/lfx-one/e2e/create-quick-link.spec.ts index fda9fb658..fa4ea43f4 100644 --- a/apps/lfx-one/e2e/create-quick-link.spec.ts +++ b/apps/lfx-one/e2e/create-quick-link.spec.ts @@ -21,6 +21,7 @@ * 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 @@ -180,4 +181,26 @@ test.describe('Create Quick-Link — rail popover + dialog smoke set', () => { 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=. Assert we land on the meetings create page carrying that project slug. + await expect(page).toHaveURL(new RegExp(`/meetings/create\\?.*project=${slug}`), { timeout: 15_000 }); + }); }); 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 index b7a1a10f8..a2154a9bc 100644 --- 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 @@ -8,7 +8,7 @@
-

{{ createLabel }}

+

{{ createLabel }}

{{ artifact.description }}

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 640e3040f..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,13 +69,15 @@ Linux Foundation - + @if (createPermissionService.canShowCreateButton()) {