diff --git a/src/app/(app)/products/new/page.tsx b/src/app/(app)/products/new/page.tsx index 414d59f8..6f7a5b25 100644 --- a/src/app/(app)/products/new/page.tsx +++ b/src/app/(app)/products/new/page.tsx @@ -6,7 +6,12 @@ import { Actions, DataConnectionObjectSchema, MembershipState } from "@/types"; import { Heading, Text } from "@radix-ui/themes"; import { FormTitle } from "@/components/core"; -export default async function NewProductPage() { +export default async function NewProductPage({ + searchParams, +}: { + searchParams: Promise<{ owner?: string }>; +}) { + const { owner } = await searchParams; const session = await getPageSession(); if (!session?.account) { return ( @@ -64,6 +69,13 @@ export default async function NewProductPage() { a.account_id === owner) + ? owner + : undefined + } /> ); diff --git a/src/components/features/products/ProductCreationForm.tsx b/src/components/features/products/ProductCreationForm.tsx index 5444e3fa..c3fb4856 100644 --- a/src/components/features/products/ProductCreationForm.tsx +++ b/src/components/features/products/ProductCreationForm.tsx @@ -62,6 +62,7 @@ interface ProductCreationFormProps { dataConnections?: DataConnection[]; // Connections the user may create against product?: Product; // Optional product for edit mode mode?: "create" | "edit"; // Mode of operation + defaultOwnerId?: string; // Preselected owner (e.g. from ?owner=…), create mode } export function ProductCreationForm({ @@ -69,12 +70,16 @@ export function ProductCreationForm({ dataConnections = [], product, mode = "create", + defaultOwnerId, }: ProductCreationFormProps) { const isEditMode = mode === "edit" && product; - const [accountId, setAccountId] = useState( - isEditMode ? product.account_id : potentialOwnerAccounts[0]?.account_id - ); + // In create mode, start on the preselected owner when given, else the first. + const initialOwnerId = isEditMode + ? product.account_id + : (defaultOwnerId ?? potentialOwnerAccounts[0]?.account_id); + + const [accountId, setAccountId] = useState(initialOwnerId); const [productId, setProductId] = useState( isEditMode ? product.product_id : "" ); @@ -90,10 +95,7 @@ export function ProductCreationForm({ // Data connection selection. In edit mode the storage backend is fixed once // the product exists, so we don't offer a selector — but we still resolve the // product's connection to constrain the visibility options. - const initialAccountId = isEditMode - ? product.account_id - : potentialOwnerAccounts[0]?.account_id; - const initialAvailable = connectionsForAccount(initialAccountId ?? ""); + const initialAvailable = connectionsForAccount(initialOwnerId ?? ""); const [dataConnectionId, setDataConnectionId] = useState( isEditMode diff --git a/src/components/features/uploader/UploadsSubmenu.tsx b/src/components/features/uploader/UploadsSubmenu.tsx index 861afd86..1e637525 100644 --- a/src/components/features/uploader/UploadsSubmenu.tsx +++ b/src/components/features/uploader/UploadsSubmenu.tsx @@ -6,7 +6,6 @@ import { DropdownMenu, Text, Badge, - Box, Progress, IconButton, ScrollArea, @@ -152,7 +151,6 @@ export function UploadsSubmenu() { - ); } diff --git a/src/components/layout/AccountDropdown.tsx b/src/components/layout/AccountDropdown.tsx index 517314c7..2cf39787 100644 --- a/src/components/layout/AccountDropdown.tsx +++ b/src/components/layout/AccountDropdown.tsx @@ -1,20 +1,19 @@ "use client"; -import { useState } from "react"; +import { useState, type CSSProperties } from "react"; import { Flex, DropdownMenu, Text, Box } from "@radix-ui/themes"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { ChevronDownIcon, PlusIcon, DashIcon } from "@radix-ui/react-icons"; import styles from "./Navigation.module.css"; import { - LOGGER, - CONFIG, accountUrl, - editAccountProfileUrl, newOrganizationUrl, newProductUrl, + productUrl, } from "@/lib"; -import { DropdownSection } from "./DropdownSection"; +import { DropdownSection, DropdownSubmenu } from "./DropdownSection"; +import { logout } from "./logout"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; import { ADMIN_TOOLS } from "@/components/features/admin/tools"; -import { Actions, UserSession } from "@/types"; +import { Account, Actions, UserSession } from "@/types"; import { ProfileAvatar } from "@/components/features/profiles/ProfileAvatar"; import { UploadBadge } from "@/components/features/uploader/UploadBadge"; import { UploadsSubmenu } from "@/components/features/uploader/UploadsSubmenu"; @@ -29,27 +28,47 @@ export function AccountDropdownSkeleton() { ); } -export function AccountDropdown({ session }: { session: UserSession }) { - const [isOpen, setIsOpen] = useState(false); +// Cap an account/product/invitation name at a fixed width so it ellipsizes +// (text-overflow needs a definite width) instead of widening the menu. +const entityNameStyle: CSSProperties = { + display: "block", + maxWidth: 180, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +export interface DropdownAccountProduct { + product_id: string; + title: string; +} - const handleLogout = async () => { - const response = await fetch(CONFIG.auth.routes.logout, { - method: "GET", - credentials: "include", - }); - if (!response.ok) { - LOGGER.error( - `Failed to logout: ${response.status} ${response.statusText}`, - { - operation: "logout", - metadata: { response }, - } - ); - return; - } - const { logout_url } = await response.json(); - window.location.href = logout_url; - }; +// An account the user can browse from the menu: themselves or an org they +// belong to, plus that account's products. The full account drives the avatar. +export interface DropdownAccount { + account: Account; + isSelf: boolean; + products: DropdownAccountProduct[]; +} + +export interface DropdownInvitation { + href: string; + label: string; +} + +export function AccountDropdown({ + session, + accounts = [], + pendingInvitations = [], +}: { + session: UserSession; + accounts?: DropdownAccount[]; + pendingInvitations?: DropdownInvitation[]; +}) { + const [isOpen, setIsOpen] = useState(false); + const hasInvitations = pendingInvitations.length > 0; + const canCreateProduct = isAuthorized(session, "*", Actions.CreateRepository); + const canCreateOrg = isAuthorized(session, "*", Actions.CreateAccount); return ( @@ -58,6 +77,21 @@ export function AccountDropdown({ session }: { session: UserSession }) { + {hasInvitations && ( + + )} {session.account!.name} @@ -70,40 +104,121 @@ export function AccountDropdown({ session }: { session: UserSession }) { + {/* One submenu per account (you + your orgs): an avatar-labelled trigger + linking to the account page, then that account's products. */} + {accounts.map(({ account, isSelf, products }) => ( + + + {account.name} + {isSelf && ( + + you + + )} + + } + items={[ + { + href: accountUrl(account.account_id), + children: isSelf ? "View profile" : "View organization", + }, + ]} + actionsLabel="Products" + actions={[ + ...(products.length > 0 + ? products.slice(0, 20).map((product) => ({ + href: productUrl(account.account_id, product.product_id), + children: ( + <> + + {product.title} + + ), + })) + : [ + { + children: "No products yet", + color: "gray" as const, + disabled: true, + }, + ]), + // Create a product for this account, at the bottom of its list. + { + href: canCreateProduct + ? newProductUrl(account.account_id) + : undefined, + disabled: !canCreateProduct, + tooltip: canCreateProduct + ? undefined + : "You don't have permission to create products", + children: ( + <> + + New product + + ), + }, + ]} + /> + ))} + + + {/* New organization, under the list of accounts you belong to. + Always shown; disabled with a tooltip when unauthorized. */} - + + New organization + + ), }, ]} /> - + {/* Invitations — always shown so the user can check; a grey empty state + when there are none, a red dot on the label when there are. */} + + Invitations + {hasInvitations && } + + } + items={ + hasInvitations + ? pendingInvitations.slice(0, 20).map((invitation) => ({ + href: invitation.href, + children: ( + {invitation.label} + ), + })) + : [ + { + children: "No pending invitations", + color: "gray" as const, + disabled: true, + }, + ] + } /> - ({ @@ -112,10 +227,11 @@ export function AccountDropdown({ session }: { session: UserSession }) { }))} /> + ; + const self = session.account; + const memberships = session.memberships ?? []; + // Accepted org memberships (distinct) vs outstanding invites: an invite + // must not read as an org you belong to — it goes in "Invitations" instead. + const memberOrgIds = [ + ...new Set( + memberships + // Org-level memberships only — a product-level membership doesn't make + // you an org member (matches OrganizationProfilePage's isMember). + .filter( + (m) => + m.state === MembershipState.Member && + m.repository_id === undefined + ) + .map((m) => m.membership_account_id) + ), + ]; + const invited = memberships.filter( + (m) => m.state === MembershipState.Invited + ); + + // One round trip: org accounts (member + invited, for names), the products + // owned by you and by each org you belong to, and titles for product invites. + const productAccountIds = [self.account_id, ...memberOrgIds]; + const [orgAccounts, productLists, invitedProducts] = await Promise.all([ + accountsTable.fetchManyByIds([ + ...memberOrgIds, + ...invited.map((m) => m.membership_account_id), + ]), + Promise.all( + productAccountIds.map((id) => + // Over-fetch, then filter by authz, then cap in toProducts — capping + // at the query would drop authorized products behind restricted ones. + // ponytail: 100-deep window; paginate if accounts commonly exceed it. + productsTable.listByAccount(id, 100).then((r) => r.products) + ) + ), + Promise.all( + invited + .filter((m) => m.repository_id) + .map((m) => + productsTable.fetchById(m.membership_account_id, m.repository_id!) + ) + ), + ]); + + const accountsById = new Map(orgAccounts.map((a) => [a.account_id, a])); + const productsByAccount = new Map( + productAccountIds.map((id, i) => [id, productLists[i]]) + ); + // Only surface products the user is authorized to see, so an org member + // isn't shown the org's restricted products they can't access (matches + // IndividualProfilePage's GetRepository filter). + const toProducts = (id: string) => + (productsByAccount.get(id) ?? []) + .filter((p) => isAuthorized(session, p, Actions.GetRepository)) + .slice(0, 20) + .map((p) => ({ product_id: p.product_id, title: p.title })); + + // Accounts you can browse: yourself first, then each org you belong to. + const accounts = [ + { account: self, isSelf: true, products: toProducts(self.account_id) }, + ...memberOrgIds + .map((id) => accountsById.get(id)) + .filter((a): a is Account => !!a && isOrganizationalAccount(a)) + .map((a) => ({ + account: a, + isSelf: false, + products: toProducts(a.account_id), + })), + ]; + + const productTitleByKey = new Map(); + for (const p of invitedProducts) { + if (p) productTitleByKey.set(`${p.account_id}/${p.product_id}`, p.title); + } + const pendingInvitations = invited.map((m) => + invitationLink(m, { + organizationName: accountsById.get(m.membership_account_id)?.name, + productTitle: m.repository_id + ? productTitleByKey.get(`${m.membership_account_id}/${m.repository_id}`) + : undefined, + }) + ); + + return ( + <> + {/* Desktop: Products link (+ divider) + account dropdown */} + + + + + {/* Mobile: single hamburger → full-screen sheet */} + + + + + ); } if (session && !session.account) { @@ -29,8 +144,18 @@ export async function AuthButtons() { const returnTo = await getReturnToUrl(); return ( - - - + <> + {/* Desktop: Products link + login inline */} + + + + + + + {/* Mobile: hamburger → sheet with Products + login */} + + + + ); } diff --git a/src/components/layout/DropdownSection.tsx b/src/components/layout/DropdownSection.tsx index e16314a1..dee0c361 100644 --- a/src/components/layout/DropdownSection.tsx +++ b/src/components/layout/DropdownSection.tsx @@ -1,5 +1,5 @@ "use client"; -import { DropdownMenu } from "@radix-ui/themes"; +import { DropdownMenu, Tooltip } from "@radix-ui/themes"; import Link from "next/link"; import { CSSProperties, ReactNode } from "react"; @@ -22,8 +22,62 @@ export interface DropdownItem { color?: DropdownMenu.ItemProps["color"]; disabled?: boolean; condition?: boolean; + /** Shown on hover when the item is disabled (e.g. why an action is blocked). */ + tooltip?: ReactNode; } +// Render a list of menu items, honoring each item's `condition`. Shared by the +// flat DropdownSection and the DropdownSubmenu so links/anchors behave the same +// in both. +function DropdownItems({ items }: { items: DropdownItem[] }) { + return items + // Keep the original array index so the React key is stable when an item's + // `condition` toggles — filtering first would renumber surviving items and + // let React reconcile the wrong node. + .map((item, index) => ({ item, index })) + .filter(({ item: { condition = true } }) => condition) + .map(({ item, index }) => + item.disabled && item.tooltip ? ( + // Blocked action: a real (disabled) menu item keeps the menu's padding, + // wrapped in a span so pointer-events stay alive for the tooltip to fire. + + + + {item.children} + + + + ) : item.href ? ( + // `asChild` makes the itself the menu item: one real anchor + // handles left-click (Next client-side nav), keyboard activation + // (Radix triggers the anchor), and open-in-new-tab — a single + // navigation with no duplicate history entry. + + + {item.children} + + + ) : ( + + {item.children} + + ) + ); +} + +const visibleCount = (items: DropdownItem[]) => + items.filter(({ condition = true }) => condition).length; + export interface DropdownSectionProps { label?: string; items: DropdownItem[]; @@ -38,50 +92,58 @@ export function DropdownSection({ condition = true, }: DropdownSectionProps) { // Only render if condition is true and there are items to show - if ( - !condition || - items.filter(({ condition = true }) => condition).length === 0 - ) { + if (!condition || visibleCount(items) === 0) { return null; } return ( <> {label && {label}} - {items - // Keep the original array index so the React key is stable when an - // item's `condition` toggles — filtering on its own would renumber - // surviving items and let React reconcile the wrong node. - .map((item, index) => ({ item, index })) - .filter(({ item: { condition = true } }) => condition) - .map(({ item, index }) => - item.href ? ( - // `asChild` makes the itself the menu item: one real anchor - // handles left-click (Next client-side nav), keyboard activation - // (Radix triggers the anchor), and open-in-new-tab — a single - // navigation with no duplicate history entry. - - - {item.children} - - - ) : ( - - {item.children} - - ) - )} + {showSeparator && } ); } + +export interface DropdownSubmenuProps { + label: ReactNode; + /** Listed first (e.g. the user's organizations/products). */ + items: DropdownItem[]; + /** Shown after a separator (e.g. the products, or a "Create…" action). */ + actions?: DropdownItem[]; + /** Optional heading above the actions (e.g. "Products") to label the list. */ + actionsLabel?: string; + condition?: boolean; +} + +export function DropdownSubmenu({ + label, + items, + actions = [], + actionsLabel, + condition = true, +}: DropdownSubmenuProps) { + const hasItems = visibleCount(items) > 0; + const hasActions = visibleCount(actions) > 0; + if (!condition || (!hasItems && !hasActions)) { + return null; + } + + return ( + + {label} + {/* Cap height so a long list (e.g. up to 20 orgs/products) scrolls + instead of running off-screen. */} + + + {hasItems && hasActions && } + {actionsLabel && hasActions && ( + {actionsLabel} + )} + + + + ); +} diff --git a/src/components/layout/MobileMenu.tsx b/src/components/layout/MobileMenu.tsx new file mode 100644 index 00000000..385a2bb6 --- /dev/null +++ b/src/components/layout/MobileMenu.tsx @@ -0,0 +1,319 @@ +"use client"; +import { useState, type ReactNode } from "react"; +import NextLink from "next/link"; +import { Button, Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; +import { + HamburgerMenuIcon, + Cross1Icon, + ChevronDownIcon, + PlusIcon, + DashIcon, +} from "@radix-ui/react-icons"; +import styles from "./Navigation.module.css"; +import { + accountUrl, + productUrl, + productListUrl, + newProductUrl, + newOrganizationUrl, + loginUrl, + docsUrl, +} from "@/lib"; +import { isAdmin, isAuthorized } from "@/lib/api/authz"; +import { ADMIN_TOOLS } from "@/components/features/admin/tools"; +import { Actions, UserSession } from "@/types"; +import { ProfileAvatar } from "@/components/features/profiles/ProfileAvatar"; +import { logout } from "./logout"; +import type { DropdownAccount, DropdownInvitation } from "./AccountDropdown"; + +// Hamburger + full-screen sheet shell (themed Radix Dialog re-applies the theme +// inside the portal). Children receive a `close` to dismiss on navigation. +function MobileMenuSheet({ + children, +}: { + children: (close: () => void) => ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + + + + + Menu + + + + + + + +
+ {children(() => setOpen(false))} + + + ); +} + +export function MobileMenu({ + session, + accounts, + pendingInvitations, +}: { + session: UserSession; + accounts: DropdownAccount[]; + pendingInvitations: DropdownInvitation[]; +}) { + // Single-open accordion: one expanded section at a time (account id / "…"). + const [expanded, setExpanded] = useState(null); + const canCreateProduct = isAuthorized(session, "*", Actions.CreateRepository); + const canCreateOrg = isAuthorized(session, "*", Actions.CreateAccount); + const hasInvitations = pendingInvitations.length > 0; + const toggle = (key: string) => + setExpanded((cur) => (cur === key ? null : key)); + + return ( + + {(close) => ( + <> + + Products + + + Docs + +
+ + {accounts.map(({ account, isSelf, products }) => ( +
toggle(account.account_id)} + label={ + + + {account.name} + {isSelf && ( + + you + + )} + + } + > + + {isSelf ? "View profile" : "View organization"} + + {products.length > 0 ? ( + products.map((p) => ( + } + > + {p.title} + + )) + ) : ( + No products yet + )} + {canCreateProduct && ( + } + > + New product + + )} +
+ ))} + + {canCreateOrg &&
} + {canCreateOrg && ( + } + > + New organization + + )} + +
+
toggle("invitations")} + label={ + + Invitations + {hasInvitations && } + + } + > + {hasInvitations ? ( + pendingInvitations.map((inv, i) => ( + + {inv.label} + + )) + ) : ( + No pending invitations + )} +
+ + {isAdmin(session) && ( +
toggle("admin")} + label={Admin} + > + {ADMIN_TOOLS.map((tool) => ( + + {tool.name} + + ))} +
+ )} + +
+ + + )} + + ); +} + +/** Logged-out: the same hamburger sheet with just Products + login. */ +export function LoggedOutMobileMenu({ returnTo }: { returnTo?: string }) { + return ( + + {(close) => ( + <> + + Products + + + Docs + +
+ + + + + )} + + ); +} + +function Row({ + href, + onNavigate, + icon, + indent, + children, +}: { + href: string; + onNavigate: () => void; + icon?: ReactNode; + indent?: boolean; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +function MutedRow({ + indent, + children, +}: { + indent?: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function Section({ + expanded, + onToggle, + label, + children, +}: { + expanded: boolean; + onToggle: () => void; + label: ReactNode; + children: ReactNode; +}) { + return ( + <> + + {expanded &&
{children}
} + + ); +} diff --git a/src/components/layout/NavLinks.tsx b/src/components/layout/NavLinks.tsx new file mode 100644 index 00000000..a046f029 --- /dev/null +++ b/src/components/layout/NavLinks.tsx @@ -0,0 +1,30 @@ +"use client"; +import { usePathname } from "next/navigation"; +import { Link, Separator } from "@radix-ui/themes"; +import NextLink from "next/link"; +import styles from "./Navigation.module.css"; +import { docsUrl, homeUrl, productListUrl } from "@/lib"; + +/** + * Top-level nav links (Products + Docs) — shown to everyone, except on the + * marketing homepage (which has its own sparse nav). Optionally trailed by a + * vertical divider so the links + divider hide together. + */ +export function NavLinks({ divider = false }: { divider?: boolean }) { + const pathname = usePathname(); + if (pathname === homeUrl()) return null; + + return ( + <> + + Products + + + Docs + + {divider && ( + + )} + + ); +} diff --git a/src/components/layout/Navigation.module.css b/src/components/layout/Navigation.module.css index 47924dd1..eb7aff1e 100644 --- a/src/components/layout/Navigation.module.css +++ b/src/components/layout/Navigation.module.css @@ -8,10 +8,100 @@ color: inherit; } +/* Products nav link — heading font, matches the product-list entry headers. + Scoped under .nav so it outranks the marketing page's `.landing a` rule. */ +.nav .productsLink { + font-family: var(--code-font-family); /* Berkeley Mono */ + font-weight: 500; + color: var(--gray-a11); + text-transform: none; + text-decoration: none; + transition: color 0.15s ease; +} + +.nav .productsLink:hover { + color: var(--gray-12); +} + .chevron { transition: transform 0.3s ease; } .chevron[data-state="open"] { transform: rotate(180deg); +} + +/* Mobile menu — full-screen override of the themed Radix Dialog content + (the Dialog supplies the backdrop, panel background, and theme tokens). */ +.mobileSheet { + position: fixed; + inset: 0; + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + margin: 0; + padding: 0; + border-radius: 0; + box-shadow: none; + display: flex; + flex-direction: column; + overflow-y: auto; + background: var(--color-panel-solid); +} + +.mobileDivider { + border-top: 1px solid var(--gray-a5); +} + +/* Full-width, tap-friendly rows (links, section headers, logout). */ +.mobileRow { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + min-height: 48px; + padding: 0 var(--space-4); + font-size: var(--font-size-3); + color: var(--gray-12); + text-align: left; + text-decoration: none; + background: transparent; + border: 0; + cursor: pointer; +} + +.mobileRow:hover { + background: var(--gray-3); +} + +.mobileRowIndent { + padding-left: var(--space-7); +} + +.mobileRowMuted { + color: var(--gray-a11); + cursor: default; +} + +.mobileRowMuted:hover { + background: transparent; +} + +.mobileRowChevron { + margin-left: auto; + color: var(--gray-a11); + transition: transform 0.15s ease; +} + +.mobileRowChevronOpen { + transform: rotate(180deg); +} + +.mobileDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--red-9); } \ No newline at end of file diff --git a/src/components/layout/Navigation.module.css.d.ts b/src/components/layout/Navigation.module.css.d.ts index 6a1bb01c..7e454976 100644 --- a/src/components/layout/Navigation.module.css.d.ts +++ b/src/components/layout/Navigation.module.css.d.ts @@ -2,6 +2,15 @@ declare const styles: { readonly nav: string; readonly logo: string; readonly chevron: string; + readonly productsLink: string; + readonly mobileSheet: string; + readonly mobileDivider: string; + readonly mobileRow: string; + readonly mobileRowIndent: string; + readonly mobileRowMuted: string; + readonly mobileRowChevron: string; + readonly mobileRowChevronOpen: string; + readonly mobileDot: string; }; -export default styles; \ No newline at end of file +export default styles; diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 30860b43..1252ed95 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -9,14 +9,14 @@ export async function Navigation() { return ( diff --git a/src/components/layout/__tests__/DropdownSubmenu.test.tsx b/src/components/layout/__tests__/DropdownSubmenu.test.tsx new file mode 100644 index 00000000..3fbbb981 --- /dev/null +++ b/src/components/layout/__tests__/DropdownSubmenu.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import { DropdownSubmenu } from "../DropdownSection"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +// Mirror DropdownSection.test.tsx: mock the Radix primitives down to plain DOM +// so we assert structure (which items/actions render, and the separator +// between them) without the real popper/Slot machinery. +jest.mock("@radix-ui/themes", () => ({ + DropdownMenu: { + Sub: ({ children }: { children: React.ReactNode }) =>
{children}
, + SubTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SubContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Item: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => + asChild ? <>{children} :
{children}
, + Separator: () =>
, + }, +})); + +describe("DropdownSubmenu", () => { + it("renders the label, items, then actions", () => { + render( + + ); + expect(screen.getByText("Organizations")).toBeInTheDocument(); + expect(screen.getByText("Acme")).toBeInTheDocument(); + expect(screen.getByText("Add Organization")).toBeInTheDocument(); + // separator only when there are BOTH items and actions + expect(screen.getByTestId("separator")).toBeInTheDocument(); + }); + + it("omits the separator when there are no items (actions only)", () => { + render( + + ); + expect(screen.getByText("All Products")).toBeInTheDocument(); + expect(screen.queryByTestId("separator")).not.toBeInTheDocument(); + }); + + it("renders nothing when condition is false", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when no items or actions are visible", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/layout/__tests__/accountMenu.test.ts b/src/components/layout/__tests__/accountMenu.test.ts new file mode 100644 index 00000000..d1cf6237 --- /dev/null +++ b/src/components/layout/__tests__/accountMenu.test.ts @@ -0,0 +1,30 @@ +import { invitationLink } from "../accountMenu"; + +describe("invitationLink", () => { + it("routes an org invite to the org page, using the org name", () => { + expect( + invitationLink( + { membership_account_id: "acme", repository_id: undefined }, + { organizationName: "Acme Corp" } + ) + ).toEqual({ href: "/acme", label: "Acme Corp" }); + }); + + it("routes a product invite to the product page, using the product title", () => { + expect( + invitationLink( + { membership_account_id: "acme", repository_id: "roads" }, + { productTitle: "Road Network" } + ) + ).toEqual({ href: "/acme/roads", label: "Road Network" }); + }); + + it("falls back to ids when names are missing", () => { + expect( + invitationLink({ membership_account_id: "acme", repository_id: undefined }, {}) + ).toEqual({ href: "/acme", label: "acme" }); + expect( + invitationLink({ membership_account_id: "acme", repository_id: "roads" }, {}) + ).toEqual({ href: "/acme/roads", label: "acme/roads" }); + }); +}); diff --git a/src/components/layout/accountMenu.ts b/src/components/layout/accountMenu.ts new file mode 100644 index 00000000..5530c270 --- /dev/null +++ b/src/components/layout/accountMenu.ts @@ -0,0 +1,30 @@ +import { accountUrl, productUrl } from "@/lib/urls"; +import type { Membership } from "@/types"; + +export interface InvitationLink { + href: string; + label: string; +} + +/** + * Where a pending invitation should link so the user can accept it: the product + * page for a product invite (repository_id set), otherwise the organization + * page. Both pages render the PendingInvitationBanner. Falls back to ids when a + * display name couldn't be resolved. + */ +export function invitationLink( + membership: Pick, + names: { organizationName?: string; productTitle?: string } +): InvitationLink { + const { membership_account_id, repository_id } = membership; + if (repository_id) { + return { + href: productUrl(membership_account_id, repository_id), + label: names.productTitle ?? `${membership_account_id}/${repository_id}`, + }; + } + return { + href: accountUrl(membership_account_id), + label: names.organizationName ?? membership_account_id, + }; +} diff --git a/src/components/layout/logout.ts b/src/components/layout/logout.ts new file mode 100644 index 00000000..1bb5d1b4 --- /dev/null +++ b/src/components/layout/logout.ts @@ -0,0 +1,18 @@ +import { CONFIG, LOGGER } from "@/lib"; + +/** Shared logout handler for the desktop dropdown and the mobile menu. */ +export async function logout() { + const response = await fetch(CONFIG.auth.routes.logout, { + method: "GET", + credentials: "include", + }); + if (!response.ok) { + LOGGER.error( + `Failed to logout: ${response.status} ${response.statusText}`, + { operation: "logout", metadata: { response } } + ); + return; + } + const { logout_url } = await response.json(); + window.location.href = logout_url; +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts index cd721b1e..0fff5907 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -10,7 +10,10 @@ export const productUrl = ( params?: string ) => `/${account_id}/${product_id}` + (params ? `?${params}` : ""); export const productListUrl = () => "/products"; -export const newProductUrl = () => "/products/new"; +export const newProductUrl = (ownerAccountId?: string) => + "/products/new" + + (ownerAccountId ? `?owner=${encodeURIComponent(ownerAccountId)}` : ""); +export const docsUrl = () => "https://docs.source.coop"; // Admin URLs export const adminUrl = () => "/admin";