diff --git a/.changeset/plugin-directory-browse.md b/.changeset/plugin-directory-browse.md new file mode 100644 index 0000000000..bdf400a14c --- /dev/null +++ b/.changeset/plugin-directory-browse.md @@ -0,0 +1,32 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +The admin now has a plugin directory, at Plugins then Browse plugins. + +It lists the plugins Nextly publishes with a description, category and author, marks the ones already installed, and searches by name, description and tags. A curated row sits above the grid while there is more in the grid than in the row. + +It is discovery only. Installing a plugin means adding a dependency and a line to `nextly.config.ts`, so the directory never writes to your source or changes plugin state. Where a listed plugin is already installed, its own icon and description are shown rather than the directory's copy of them. diff --git a/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx b/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx index 19070770c8..65ac81f685 100644 --- a/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx +++ b/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx @@ -44,9 +44,34 @@ vi.mock("@admin/hooks/queries", () => ({ })); import { DynamicPluginNav } from "@admin/components/features/dashboard/DynamicPluginNav"; import { SidebarProvider } from "@admin/components/layout/sidebar"; +import { useSidebarNavigation } from "@admin/hooks/useSidebarNavigation"; const noop = () => false; +/** + * Renders the panel with the REAL `isActive`, for the one pathname given. + * + * The sidebar's matcher rather than one written here: a copy would agree on + * the day it was written and then answer for itself, and what is under test is + * how the overview composes with that matcher, not whether the composition can + * be restated. + * + * Empty navigation items because `isActive` reads only the pathname; the items + * feed accordion state, which nothing here asserts on. + */ +function ActiveStateHarness({ pathname }: { pathname: string }) { + const { isActive } = useSidebarNavigation([], pathname); + return ; +} + +function renderAt(pathname: string) { + return render( + + + + ); +} + // The real provider rather than a mocked `useSidebar`: the component reads // collapsed state from it, so a stub would let a change to that contract pass // unnoticed here. @@ -192,6 +217,41 @@ describe("DynamicPluginNav", () => { }); }); +/** + * Which sidebar entry the overview claims. + * + * A plugin's own pages are descendants of `/admin/plugins`, so an exact match + * would leave the secondary navigation with nothing selected while reading a + * plugin's detail page. The directory is the case pulling the other way, and + * it is answered by living under a different prefix rather than by a rule + * here — which is what the last case pins. + */ +describe("DynamicPluginNav overview active state", () => { + function overviewActive() { + return screen + .getByRole("link", { name: /installed plugins/i }) + .getAttribute("data-active"); + } + + it.each([ + ["/admin/plugins", "true"], + ["/admin/plugins/acme-forms", "true"], + ["/admin/plugins/acme-forms/settings", "true"], + // A plugin that happens to be called "browse" is still a plugin, and its + // page belongs to this entry like any other. + ["/admin/plugins/browse", "true"], + // The directory, which Browse owns. It is outside the prefix, so this is + // false without the overview having to exclude anything. + ["/admin/plugin-directory", "false"], + ])("at %s the overview is active=%s", (pathname, expected) => { + mockBranding = { plugins: [] } as unknown as AdminBranding; + + renderAt(pathname); + + expect(overviewActive()).toBe(expected); + }); +}); + describe("DynamicPluginNav expanded", () => { /** * A group is expandable when it retains a collection, and only then. diff --git a/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx b/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx index 7ed82c2ebf..fad8200700 100644 --- a/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx +++ b/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx @@ -56,7 +56,11 @@ function PluginOverviewLink({ }: { isActive: (href?: string) => boolean; }) { - const active = isActive("/admin/plugins"); + // The whole subtree, so a plugin's own pages — `/admin/plugins/` and + // its settings — keep the overview selected. Nothing has to be subtracted + // here: the directory lives outside this prefix, so it cannot be caught by + // a descendant match and light both entries at once. + const active = isActive(ROUTES.PLUGINS); return ( diff --git a/packages/admin/src/components/icons/index.ts b/packages/admin/src/components/icons/index.ts index b7089ae806..4931e48e29 100644 --- a/packages/admin/src/components/icons/index.ts +++ b/packages/admin/src/components/icons/index.ts @@ -108,6 +108,7 @@ export { Italic, // Rich text editor Key, Laptop, + Layout, // Plugin appearance: the icon @nextlyhq/plugin-page-builder declares LayoutGrid, // Collection Builder: blocks field Layers, LayoutDashboard, @@ -219,3 +220,5 @@ export const Discord = ({ size = 24, ...props }: LucideProps) => { }) ); }; + +export type { AdminIconName } from "./names"; diff --git a/packages/admin/src/components/icons/names.ts b/packages/admin/src/components/icons/names.ts new file mode 100644 index 0000000000..e081fc3c95 --- /dev/null +++ b/packages/admin/src/components/icons/names.ts @@ -0,0 +1,25 @@ +/** + * The set of icon names the barrel can render. + * + * Its own module rather than a line in the barrel, because the type has to + * refer to the barrel's own exports and a module cannot describe itself + * without the inline `import()` form that this package's lint rules forbid. + * + * @module components/icons/names + */ +import type * as Icons from "./index"; + +/** + * Every icon name this admin can render, derived from the exports rather than + * listed beside them, so a name and the export it refers to cannot disagree. + * + * Curated data that picks an icon — the plugin catalogue — types its icon + * field as this, and a name the barrel does not carry stops compiling instead + * of resolving to nothing and silently rendering the caller's generic + * fallback. + * + * Not for icon names that arrive at runtime: a third-party plugin declares + * `appearance.icon` as a free string this admin cannot constrain, so that path + * keeps its fallback. + */ +export type AdminIconName = keyof typeof Icons; diff --git a/packages/admin/src/components/layout/sidebar/DualSidebar.tsx b/packages/admin/src/components/layout/sidebar/DualSidebar.tsx index fa3ae74c6f..ef753415ef 100644 --- a/packages/admin/src/components/layout/sidebar/DualSidebar.tsx +++ b/packages/admin/src/components/layout/sidebar/DualSidebar.tsx @@ -23,6 +23,7 @@ import { import { resolveCollectionPlacement } from "@admin/lib/plugins/collection-placement"; import { pluginSlug } from "@admin/lib/plugins/plugin-slug"; import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon"; +import { isUnder } from "@admin/lib/routing"; import { cn } from "@admin/lib/utils"; import type { ApiCollection } from "@admin/types/entities"; @@ -338,8 +339,13 @@ export function DualSidebar({ isMobile }: DualSidebarProps = {}) { }); }; + // The route constants rather than their spellings. The plugin directory + // sits at its own top level so no plugin slug can shadow it, which means + // the Plugins category is not one URL prefix and a literal would silently + // stop covering it the next time either route moves. if ( - pathname.includes("/admin/plugins") || + isUnder(pathname, ROUTES.PLUGINS) || + isUnder(pathname, ROUTES.PLUGIN_BROWSE) || pathname.includes("/admin/forms") || isPluginPath(collectionsData) ) { diff --git a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx index e7ddf0045a..bd2f23788b 100644 --- a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx +++ b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx @@ -127,6 +127,29 @@ export function SubSidebarContent({

+ {/* Gated on the permission `PLUGIN_BROWSE` itself requires. The + panel stays open to a user who can only read a plugin-owned + collection, so without this they would be offered a destination + that redirects them the moment they choose it. + + Below the installed plugins, not above: this panel is for + getting to what the project already has, and the directory is + the occasional trip. Not filtered by `pluginSearch` either — + that box searches installed plugins, and an entry that ignores + it while sitting among entries that obey it reads as a bug. */} + {hasPermission("manage-settings") && ( + + + + + Browse plugins + + + + )} diff --git a/packages/admin/src/components/shared/plugin-icon/index.test.tsx b/packages/admin/src/components/shared/plugin-icon/index.test.tsx index 743b7d0b7a..e5b2293c1f 100644 --- a/packages/admin/src/components/shared/plugin-icon/index.test.tsx +++ b/packages/admin/src/components/shared/plugin-icon/index.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; +import * as Icons from "@admin/components/icons"; import { PluginIcon } from "@admin/components/shared/plugin-icon"; import type { PluginMetadata } from "@admin/types/branding"; @@ -17,6 +18,31 @@ const withAsset = (src: string, icon?: string) => >; describe("PluginIcon", () => { + /** + * The glyph `@nextlyhq/plugin-page-builder` declares. A name the barrel does + * not re-export resolves to nothing and falls through to the caller's + * fallback, so the plugin's own icon disappears everywhere in the admin + * while every surface still looks intact. + */ + it("renders a first-party plugin's declared glyph rather than the fallback", () => { + const { container } = render( + + ); + + // Compared against what the barrel's own `Layout` draws rather than a + // pinned class name: lucide ships `Layout` as an alias, so the rendered + // class is the target's and hard-coding it would tie this to which icon + // lucide happens to alias it to. + const expected = render().container.querySelector("svg"); + expect(container.querySelector("svg")?.getAttribute("class")).toBe( + expected?.getAttribute("class") + ); + expect(container.querySelector(".lucide-package")).toBeNull(); + }); + it("renders a declared asset", () => { render(); expect(screen.getByRole("presentation", { hidden: true })).toBeTruthy(); diff --git a/packages/admin/src/components/shared/plugin-icon/index.tsx b/packages/admin/src/components/shared/plugin-icon/index.tsx index 65731669a9..37963d36e9 100644 --- a/packages/admin/src/components/shared/plugin-icon/index.tsx +++ b/packages/admin/src/components/shared/plugin-icon/index.tsx @@ -2,13 +2,20 @@ import type React from "react"; import { useState } from "react"; import * as Icons from "@admin/components/icons"; -import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon"; +import { resolvePluginIconFrom } from "@admin/lib/plugins/resolve-plugin-icon"; import { cn } from "@admin/lib/utils"; import type { PluginMetadata } from "@admin/types/branding"; -interface PluginIconProps { - /** The plugin whose icon to render. Only `appearance` is read. */ - plugin: Pick; +type IconCandidate = Pick | undefined; + +interface PluginIconFromProps { + /** + * Appearance sources in precedence order; the first that declares an icon + * wins. A caller with one source passes one. Callers with two — an installed + * plugin and the catalogue entry describing it — must not decide the order + * here: ask the module that owns the precedence rule for the list. + */ + candidates: readonly IconCandidate[]; /** * The lucide icon to use when the plugin declares none. * @@ -23,6 +30,11 @@ interface PluginIconProps { alt?: string; } +interface PluginIconProps extends Omit { + /** The plugin whose icon to render. Only `appearance` is read. */ + plugin: Pick; +} + /** * Render a plugin's icon, whether it ships an image or names a lucide glyph. * @@ -33,28 +45,31 @@ interface PluginIconProps { * * @module components/shared/plugin-icon */ -export function PluginIcon({ - plugin, +export function PluginIconFrom({ + candidates, fallback, className, alt = "", -}: PluginIconProps): React.ReactElement { +}: PluginIconFromProps): React.ReactElement { // A declared asset can still fail to arrive: a mistyped path, a deleted // file, or a Content-Security-Policy that blocks the origin. Without this the // surface keeps a broken-image glyph forever, which is worse than the plain - // icon it replaced. On failure the component re-resolves with assets - // disallowed, so it lands on whatever lucide name the plugin declared beside - // the asset before reaching the caller's fallback. - // The failed URL rather than a boolean. A boolean survives client-side - // navigation between two plugin detail pages, because the router renders the - // same component type without a key, so React keeps the state: one plugin's - // broken logo would suppress the next plugin's working one. Keying on the - // source means a different asset is always attempted. - const [failedSrc, setFailedSrc] = useState(null); - const declaredAsset = plugin.appearance?.iconAsset; - const source = resolvePluginIcon(plugin, { + // icon it replaced. + // + // The URLs that have failed, not a boolean. A broken image on one candidate + // says nothing about a different image a later candidate ships, so each + // failure removes exactly one URL from consideration and the chain is + // re-resolved: the next asset is tried, and only when none load does it + // settle on a glyph. Keying on the URL also survives client-side navigation + // between two plugin detail pages, where the router renders the same + // component type without a key so React keeps this state — one plugin's + // broken logo must not suppress the next plugin's working one. + const [failedSrcs, setFailedSrcs] = useState>( + () => new Set() + ); + const source = resolvePluginIconFrom(candidates, { fallback, - allowAsset: declaredAsset !== undefined && declaredAsset !== failedSrc, + skipAssets: failedSrcs, }); if (source.kind === "asset") { @@ -67,7 +82,14 @@ export function PluginIcon({ {alt} setFailedSrc(source.src)} + onError={() => + setFailedSrcs(prev => { + if (prev.has(source.src)) return prev; + const next = new Set(prev); + next.add(source.src); + return next; + }) + } className={cn("object-contain", className)} /> ); @@ -82,3 +104,11 @@ export function PluginIcon({ return ; } + +/** The single-source case, which is every surface but the catalogue. */ +export function PluginIcon({ + plugin, + ...rest +}: PluginIconProps): React.ReactElement { + return ; +} diff --git a/packages/admin/src/constants/routes.ts b/packages/admin/src/constants/routes.ts index cfca143ebc..be6cf00dcc 100644 --- a/packages/admin/src/constants/routes.ts +++ b/packages/admin/src/constants/routes.ts @@ -107,6 +107,13 @@ export const ROUTES = { // Plugin routes PLUGINS: "/admin/plugins", + // Outside the `/admin/plugins/` namespace on purpose. A plugin's detail + // address is `/admin/plugins/`, and a slug is derived from a package + // name that may be any string, so a sibling static page there is a page a + // plugin can be named after — and whichever of the two wins, the other + // becomes unreachable. A different parent removes the collision instead of + // ranking it. + PLUGIN_BROWSE: "/admin/plugin-directory", PLUGIN_DETAIL: "/admin/plugins/[slug]", PLUGIN_SETTINGS: "/admin/plugins/[slug]/settings", } as const; diff --git a/packages/admin/src/context/providers/BrandingProvider.test.tsx b/packages/admin/src/context/providers/BrandingProvider.test.tsx new file mode 100644 index 0000000000..224ad752cb --- /dev/null +++ b/packages/admin/src/context/providers/BrandingProvider.test.tsx @@ -0,0 +1,96 @@ +/** + * Readers that conclude something from a plugin being MISSING from branding + * need to know the list arrived. `useBranding` alone cannot tell them: it + * returns `{}` both before the request answers and when the project genuinely + * has nothing, so these cover the distinction `useBrandingStatus` adds. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AdminBranding } from "@admin/types/branding"; + +const get = vi.fn(); + +vi.mock("@admin/lib/api/publicApi", () => ({ + publicApi: { get: (...args: unknown[]) => get(...args) }, +})); + +import { + BrandingProvider, + useBrandingStatus, +} from "@admin/context/providers/BrandingProvider"; + +function Probe() { + const { isPending, isUnavailable } = useBrandingStatus(); + return ( + + {isPending ? "pending" : isUnavailable ? "unavailable" : "answered"} + + ); +} + +function renderProbe(client: QueryClient) { + return render( + + + + + + ); +} + +function status() { + return screen.getByTestId("status").textContent; +} + +afterEach(() => { + get.mockReset(); + vi.restoreAllMocks(); +}); + +describe("useBrandingStatus", () => { + it("reports pending until the request answers", async () => { + get.mockReturnValue(new Promise(() => {})); + + renderProbe( + new QueryClient({ defaultOptions: { queries: { retry: false } } }) + ); + + expect(status()).toBe("pending"); + }); + + it("reports answered once branding arrives", async () => { + get.mockResolvedValue({ plugins: [] } as AdminBranding); + + renderProbe( + new QueryClient({ defaultOptions: { queries: { retry: false } } }) + ); + + await waitFor(() => expect(status()).toBe("answered")); + }); + + it("reports unavailable when the first request fails", async () => { + get.mockRejectedValue(new Error("boom")); + + renderProbe( + new QueryClient({ defaultOptions: { queries: { retry: false } } }) + ); + + await waitFor(() => expect(status()).toBe("unavailable")); + }); +}); + +/** + * NOT COVERED, stated rather than left to look covered: a background refetch + * failing while a previous response is cached must leave the status + * `answered`, since the cached response is still a complete answer. + * + * `isUnavailable` is wired to the query's `isLoadingError`, which query-core + * defines as `isError && !hasData` (`isRefetchError` is the `&& hasData` + * half), so the distinction is the library's own and holds by construction. + * A test was written for it and removed: driving a failed refetch through + * `refetchQueries` errors the cache entry without the probe observing a + * re-render, so the assertion passed against BOTH `isLoadingError` and a + * deliberately broken `isError` — coverage in appearance only. + */ diff --git a/packages/admin/src/context/providers/BrandingProvider.tsx b/packages/admin/src/context/providers/BrandingProvider.tsx index b53560a37c..ff3519a24e 100644 --- a/packages/admin/src/context/providers/BrandingProvider.tsx +++ b/packages/admin/src/context/providers/BrandingProvider.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import type React from "react"; -import { createContext, useContext, useEffect } from "react"; +import { createContext, useContext, useEffect, useMemo } from "react"; import { publicApi } from "../../lib/api/publicApi"; import { @@ -18,10 +18,55 @@ import type { // Context // ============================================================================ -const BrandingContext = createContext(undefined); +/** + * Whether the admin-meta request has answered yet, alongside its answer. + * + * The two are held together because most readers want only the answer, and + * one reader — a page that treats a plugin's ABSENCE from the list as a fact + * about the project — needs to know the list has actually arrived. Before it + * does, `branding` is undefined, which is indistinguishable from a project + * that has no plugins. + */ +interface BrandingState { + branding: AdminBranding | undefined; + /** True until the admin-meta query settles, either way. */ + isPending: boolean; + /** + * True when admin-meta has never produced an answer, so absence proves + * nothing. + * + * Not "the last request failed". Once a response is cached, a failed + * BACKGROUND refetch leaves that answer intact and still valid, and treating + * it as unavailable would replace a correct page with an error for as long + * as the server stays unreachable. + */ + isUnavailable: boolean; +} + +const BrandingContext = createContext(undefined); export function useBranding(): AdminBranding { - return useContext(BrandingContext) ?? {}; + return useContext(BrandingContext)?.branding ?? {}; +} + +/** + * The admin-meta request's state, for readers that draw a conclusion from + * something being MISSING from branding. + * + * Derived from the same context entry `useBranding` reads, rather than from a + * second query: two `useQuery` calls on one key would report their states + * independently and could disagree about whether the data has arrived. + */ +export function useBrandingStatus(): Omit { + const state = useContext(BrandingContext); + // No provider above: nothing is loading and nothing failed, which is the + // same shape a settled empty response has. A reader outside the provider is + // already reading `{}` from `useBranding`, and reporting "still pending" + // here would hang it forever. + return { + isPending: state?.isPending ?? false, + isUnavailable: state?.isUnavailable ?? false, + }; } // ============================================================================ @@ -136,7 +181,14 @@ interface BrandingProviderProps { } export function BrandingProvider({ children }: BrandingProviderProps) { - const { data: fetchedData } = useQuery({ + const { + data: fetchedData, + isPending, + // `isLoadingError`, not `isError`: the latter is also true when a + // background refetch fails while a previous response is still cached, and + // that cached response is a perfectly good answer. + isLoadingError, + } = useQuery({ queryKey: ["admin-meta"], queryFn: () => publicApi.get("/admin-meta"), // Refetch periodically to pick up changes to custom sidebar groups, @@ -148,8 +200,16 @@ export function BrandingProvider({ children }: BrandingProviderProps) { useColorInjection(fetchedData?.colors); useFaviconInjection(fetchedData?.favicon); + // Memoized because the value is now an object built here rather than the + // query's own stable `data` reference: without this every consumer of the + // context re-renders on each render of this provider. + const value = useMemo( + () => ({ branding: fetchedData, isPending, isUnavailable: isLoadingError }), + [fetchedData, isPending, isLoadingError] + ); + return ( - + {children} ); diff --git a/packages/admin/src/hooks/useSidebarNavigation.ts b/packages/admin/src/hooks/useSidebarNavigation.ts index c8fb348694..f669407502 100644 --- a/packages/admin/src/hooks/useSidebarNavigation.ts +++ b/packages/admin/src/hooks/useSidebarNavigation.ts @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import type { NavigationItem } from "../constants/navigation"; +import { isUnder } from "../lib/routing"; /** * Custom hook to manage sidebar navigation state @@ -61,9 +62,11 @@ export function useSidebarNavigation( if (exactMatch) return false; - // Sub-item match (e.g., /admin/users/create should match /admin/users) - // Ensure we match whole segments to avoid /admin/user matching /admin/users - return path.startsWith(target + "/") || path === target; + // Sub-item match (e.g., /admin/users/create should match /admin/users), + // asked of the shared helper rather than restated: the sidebar's category + // classifier answers the same question, and two spellings of "is this + // route under that one" agree until one of them is adjusted. + return isUnder(path, target); }; return { diff --git a/packages/admin/src/lib/__tests__/is-under.test.ts b/packages/admin/src/lib/__tests__/is-under.test.ts new file mode 100644 index 0000000000..622121d7af --- /dev/null +++ b/packages/admin/src/lib/__tests__/is-under.test.ts @@ -0,0 +1,50 @@ +/** + * "Is this route under that one" is asked by the sidebar's category + * classifier and by its active-item matcher. These cover the answers a + * substring test gets wrong, which is why both now ask one function. + */ +import { describe, expect, it } from "vitest"; + +import { ROUTES } from "@admin/constants/routes"; +import { isUnder } from "@admin/lib/routing"; + +describe("isUnder", () => { + it("matches the route itself and its descendants", () => { + expect(isUnder("/admin/plugins", "/admin/plugins")).toBe(true); + expect(isUnder("/admin/plugins/acme-forms", "/admin/plugins")).toBe(true); + expect( + isUnder("/admin/plugins/acme-forms/settings", "/admin/plugins") + ).toBe(true); + }); + + /** + * The separating case. `"/admin/plugins-archive".includes("/admin/plugins")` + * is true, so the substring test this replaced classified an unrelated + * sibling as a plugins page — and the miss is silent, because the wrong + * answer is a real category rather than an error. + */ + it("rejects a sibling that merely shares the prefix", () => { + expect(isUnder("/admin/plugins-archive", "/admin/plugins")).toBe(false); + expect(isUnder("/admin/plugin-directory", "/admin/plugins")).toBe(false); + }); + + it("ignores a trailing slash on either side", () => { + expect(isUnder("/admin/plugins/", "/admin/plugins")).toBe(true); + expect(isUnder("/admin/plugins", "/admin/plugins/")).toBe(true); + }); + + it("does not match a parent from a child route", () => { + expect(isUnder("/admin", "/admin/plugins")).toBe(false); + }); + + /** + * The directory is deliberately not under `/admin/plugins`, so anything + * classifying the Plugins section has to name it separately. Pinned here + * because the consequence of forgetting is a silently wrong sidebar rather + * than a failure. + */ + it("confirms the directory is outside the installed-plugins subtree", () => { + expect(isUnder(ROUTES.PLUGIN_BROWSE, ROUTES.PLUGINS)).toBe(false); + expect(isUnder(ROUTES.PLUGIN_BROWSE, ROUTES.PLUGIN_BROWSE)).toBe(true); + }); +}); diff --git a/packages/admin/src/lib/admin-version.ts b/packages/admin/src/lib/admin-version.ts new file mode 100644 index 0000000000..4e7cf94bfe --- /dev/null +++ b/packages/admin/src/lib/admin-version.ts @@ -0,0 +1,36 @@ +/** + * The release this admin bundle belongs to. + * + * Injected at build time from `package.json` by the tsup and vitest `define` + * blocks, mirroring how core resolves `__NEXTLY_CORE_VERSION__`. No runtime + * `package.json` read as core has: this bundle targets the browser, where + * there is no module resolver to fall back to. + * + * A consumer that bundles this package from SOURCE gets `undefined`, because + * neither `define` runs. That is the contributor playground, which aliases + * `@nextlyhq/admin` to `src/index.ts` — so the install command it renders is + * unpinned, and that is correct rather than broken: a dev checkout has no + * published release to name. An installed project resolves the built `dist`, + * where the constant is a literal. + * + * @module lib/admin-version + */ + +declare const __NEXTLY_ADMIN_VERSION__: string | undefined; + +/** + * The concrete admin version (e.g. `"0.0.2-alpha.57"`), or `undefined` when + * the constant was not injected. + * + * `undefined` rather than a `"0.0.0"` sentinel, because the only caller uses + * this to pin a package specifier: a sentinel would produce an install command + * for a version that does not exist, which is worse than the unpinned command + * it replaced. Callers have to handle not knowing. + */ +export function adminVersion(): string | undefined { + const injected = + typeof __NEXTLY_ADMIN_VERSION__ !== "undefined" + ? __NEXTLY_ADMIN_VERSION__ + : undefined; + return injected && injected.length > 0 ? injected : undefined; +} diff --git a/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts b/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts new file mode 100644 index 0000000000..44988f0a44 --- /dev/null +++ b/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts @@ -0,0 +1,150 @@ +/** + * The lines a reader copies to add a plugin. + * + * Two properties matter and they are separate: the lines must be derived from + * the entry rather than stored beside it, and the install must name a version. + * An unpinned install is the failure with teeth — every first-party plugin + * declares an exact `nextly` peer for its own release, so resolving `latest` + * on a project that is not on the newest one is a peer conflict on npm and a + * startup compatibility failure elsewhere. + */ +import { describe, expect, it } from "vitest"; + +import { adminVersion } from "@admin/lib/admin-version"; + +import pkg from "../../../../../package.json"; +import { + adminImportStatement, + adminStylesImport, + importStatement, + installCommand, + pluginsArrayEntry, +} from "../install-command"; +import type { RegistryPlugin } from "../types"; + +function entry(config: RegistryPlugin["config"]): RegistryPlugin { + return { + id: "@acme/thing", + name: "Thing", + description: "d", + author: "Acme", + category: "content", + icon: { lucide: "Archive" }, + config, + }; +} + +describe("installCommand", () => { + it("pins to the given release", () => { + expect(installCommand("@acme/p", "pnpm", "1.2.3")).toBe( + "pnpm add @acme/p@1.2.3" + ); + }); + + /** + * The separating case for the pin. Asserting only that a version appears + * would pass on a build that injected a sentinel, and a command naming a + * version that was never published is worse than one naming none. + */ + it("omits the version rather than inventing one when it is unknown", () => { + expect(installCommand("@acme/p", "pnpm", undefined)).toBe( + "pnpm add @acme/p" + ); + }); + + /** + * What the call site actually passes. `adminVersion()` reads a constant the + * build injects from this package.json, so comparing the two confirms the + * injection is wired rather than merely that some string came back — a + * missing `define` would have it return undefined and the command would + * silently go back to resolving `latest`. + */ + it("names this package's own release when given the injected version", () => { + expect(installCommand("@acme/p", "pnpm", adminVersion())).toBe( + `pnpm add @acme/p@${pkg.version}` + ); + }); + + it("uses each manager's own add subcommand", () => { + expect(installCommand("@acme/p", "npm", "1.0.0")).toBe( + "npm install @acme/p@1.0.0" + ); + expect(installCommand("@acme/p", "yarn", "1.0.0")).toBe( + "yarn add @acme/p@1.0.0" + ); + expect(installCommand("@acme/p", "bun", "1.0.0")).toBe( + "bun add @acme/p@1.0.0" + ); + }); +}); + +describe("config lines", () => { + it("imports the binding the array entry references", () => { + const plugin = entry({ exportName: "thing", callArgs: "" }); + + // Asserted together, because the defect they guard is disagreement: an + // entry naming a symbol the import does not bring in compiles nowhere. + expect(importStatement(plugin)).toBe( + 'import { thing } from "@acme/thing";' + ); + expect(pluginsArrayEntry(plugin)).toBe("thing()"); + }); + + it("leaves an uncalled export uncalled", () => { + expect( + pluginsArrayEntry(entry({ exportName: "thing", callArgs: null })) + ).toBe("thing"); + }); + + /** + * A fourth edit, in the admin route page rather than the config. Skipping it + * leaves the plugin installed and its server half running while its admin UI + * silently never registers, so the recipe has to name it. + */ + it("asks for the admin module only when the package ships one", () => { + expect( + adminImportStatement( + entry({ exportName: "thing", callArgs: "", adminModule: true }) + ) + ).toBe('import "@acme/thing/admin";'); + + // The separating case: a plugin without an `/admin` export must not be + // told to import one, or the recipe names a subpath that does not resolve. + expect( + adminImportStatement(entry({ exportName: "thing", callArgs: "" })) + ).toBeUndefined(); + }); + + /** + * Separate from the admin module, because they are separate facts about the + * package. Page Builder's `/admin` entry imports no CSS, so a reader who + * takes only the module gets a registered editor with no layout at all. + */ + it("asks for the stylesheet only when the package exports one", () => { + expect( + adminStylesImport( + entry({ + exportName: "thing", + callArgs: "", + adminStyles: "styles/editor.css", + }) + ) + ).toBe('import "@acme/thing/styles/editor.css";'); + + // A plugin with an admin module but no stylesheet must not be told to + // import one — the two are independent. + expect( + adminStylesImport( + entry({ exportName: "thing", callArgs: "", adminModule: true }) + ) + ).toBeUndefined(); + }); + + it("places required arguments inside the call", () => { + expect( + pluginsArrayEntry( + entry({ exportName: "thing", callArgs: '{ collections: ["posts"] }' }) + ) + ).toBe('thing({ collections: ["posts"] })'); + }); +}); diff --git a/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts b/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts index b495bf667f..5cf57822ed 100644 --- a/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts +++ b/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts @@ -16,8 +16,11 @@ const ENTRY: RegistryPlugin = { description: "The catalogue's description", author: "Acme", category: "content", - icon: { lucide: "CatalogueGlyph" }, - configSnippet: "plugins: [thing()]", + // A real barrel export, unlike `PluginGlyph` on the installed side below: a + // catalogue entry's glyph is type-checked against the icon barrel, while a + // plugin declares its own as a free string this admin cannot constrain. + icon: { lucide: "Archive" }, + config: { exportName: "thing", callArgs: "" }, }; function installed( @@ -31,7 +34,7 @@ describe("resolveCataloguePresentation", () => { it("uses the catalogue when the plugin is not installed", () => { const p = resolveCataloguePresentation(ENTRY, undefined); - expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" }); + expect(p.icon).toEqual({ kind: "lucide", name: "Archive" }); expect(p.description).toBe("The catalogue's description"); expect(p.isInstalled).toBe(false); }); @@ -59,7 +62,7 @@ describe("resolveCataloguePresentation", () => { ); expect(p.description).toBe("The plugin's own description"); - expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" }); + expect(p.icon).toEqual({ kind: "lucide", name: "Archive" }); }); it("keeps the catalogue text when an installed plugin declares none", () => { @@ -96,7 +99,7 @@ describe("resolveCataloguePresentation", () => { it("lets an installed glyph outrank a catalogue asset", () => { const withAsset: RegistryPlugin = { ...ENTRY, - icon: { lucide: "CatalogueGlyph", asset: "/catalogue.svg" }, + icon: { lucide: "Archive", asset: "/catalogue.svg" }, }; // Positive control: with nothing installed the catalogue asset IS chosen, @@ -117,7 +120,7 @@ describe("resolveCataloguePresentation", () => { it("skips every asset for a surface that cannot render one", () => { const withAsset: RegistryPlugin = { ...ENTRY, - icon: { lucide: "CatalogueGlyph", asset: "/catalogue.svg" }, + icon: { lucide: "Archive", asset: "/catalogue.svg" }, }; const p = resolveCataloguePresentation( @@ -126,6 +129,6 @@ describe("resolveCataloguePresentation", () => { { allowAsset: false } ); - expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" }); + expect(p.icon).toEqual({ kind: "lucide", name: "Archive" }); }); }); diff --git a/packages/admin/src/lib/plugins/registry/entries.ts b/packages/admin/src/lib/plugins/registry/entries.ts index 142bdee413..b01913c175 100644 --- a/packages/admin/src/lib/plugins/registry/entries.ts +++ b/packages/admin/src/lib/plugins/registry/entries.ts @@ -31,7 +31,12 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [ category: "content", tags: ["blocks", "editor", "pages"], icon: { lucide: "Layout" }, - configSnippet: "plugins: [pageBuilder()]", + config: { + exportName: "pageBuilder", + callArgs: "", + adminModule: true, + adminStyles: "styles/editor.css", + }, links: { homepage: "https://nextlyhq.com", repository: "https://github.com/nextlyhq/nextly", @@ -45,10 +50,14 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [ category: "forms", tags: ["forms", "submissions"], icon: { lucide: "FileText" }, - // `formBuilderPlugin`, not `formBuilder()`: the factory returns a + // `callArgs: null` — the export goes in uncalled. The factory returns a // FormBuilderPluginResult whose definition is at `.plugin`, and the package // exports the unwrapped value for exactly this use. - configSnippet: "plugins: [formBuilderPlugin]", + config: { + exportName: "formBuilderPlugin", + callArgs: null, + adminModule: true, + }, links: { homepage: "https://nextlyhq.com", repository: "https://github.com/nextlyhq/nextly", @@ -63,9 +72,19 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [ category: "seo", tags: ["seo", "meta"], icon: { lucide: "Search" }, - // `seoPlugin`, and `collections` is required: the plugin adds the SEO group - // only to the collections it is given, so a bare call does not type-check. - configSnippet: 'plugins: [seoPlugin({ collections: ["posts"] })]', + // `collections` is required rather than illustrative: the plugin adds the + // SEO group only to the collections it is given, so a bare call does not + // type-check. + // + // A named placeholder, not a plausible slug like "posts". The blank + // template ships `collections: []`, and naming a collection the project + // does not have makes the eager schema fold throw + // NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN at startup — a copied line that + // stops the app is worse than one that obviously has to be edited. + config: { + exportName: "seoPlugin", + callArgs: '{ collections: ["your-collection"] }', + }, links: { homepage: "https://nextlyhq.com", repository: "https://github.com/nextlyhq/nextly", diff --git a/packages/admin/src/lib/plugins/registry/install-command.ts b/packages/admin/src/lib/plugins/registry/install-command.ts new file mode 100644 index 0000000000..823e0e69eb --- /dev/null +++ b/packages/admin/src/lib/plugins/registry/install-command.ts @@ -0,0 +1,122 @@ +/** + * The three lines that add a catalogue plugin to a project: the shell command + * that installs it, and the two edits to nextly.config.ts. + * + * All derived from the entry rather than stored beside it, so a package rename + * cannot leave the install command fetching the old name while the detail page + * joins installed state on the new one, and an entry cannot name a binding its + * import does not bring in. + * + * @module lib/plugins/registry/install-command + */ +import type { RegistryPlugin } from "./types"; + +/** + * Package managers a Nextly project can be installed with. + * + * Enumerated rather than free-form because the add subcommand differs: npm and + * bun take `install`, pnpm and yarn take `add`. A caller passing its own string + * would have to know that, which is the knowledge this module exists to hold. + */ +export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const; + +export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; + +const ADD_SUBCOMMAND: Record = { + pnpm: "add", + npm: "install", + yarn: "add", + bun: "add", +}; + +/** + * `installCommand("@acme/p", "pnpm", "1.2.3")` → `"pnpm add @acme/p@1.2.3"`. + * + * Answerable for all four managers, so a reader on npm is not handed a command + * their project cannot run. + * + * Pinned to the running admin's release, and that is not a nicety. Every + * first-party plugin declares an EXACT `nextly` peer for its own release, so + * an unpinned install on a project that is not on the newest one resolves a + * plugin whose peer names a core the project does not have: npm rejects it, + * and a manager that installs it anyway produces a plugin core refuses to load + * at startup. Admin's own version is the release the project is on, since the + * whole train publishes in lockstep and this bundle came from that install. + * + * Unpinned when `version` is undefined — a build that did not inject the + * constant. Naming a version that may not exist would be a worse answer than + * declining to name one. + * + * `version` is required rather than defaulted to `adminVersion()`, so a caller + * has to answer the question instead of inheriting an answer. A default would + * also make the unpinned branch unreachable from a test, since passing + * `undefined` re-triggers the default. + */ +export function installCommand( + packageName: string, + manager: PackageManager, + version: string | undefined +): string { + const specifier = version ? `${packageName}@${version}` : packageName; + return `${manager} ${ADD_SUBCOMMAND[manager]} ${specifier}`; +} + +/** + * The import that brings the plugin's binding into nextly.config.ts. + * + * Installing the package does not introduce the identifier, so the entry below + * references nothing without this line. It is the reader's first edit, and + * omitting it hands them a recipe that does not compile. + */ +export function importStatement(plugin: RegistryPlugin): string { + return `import { ${plugin.config.exportName} } from "${plugin.id}";`; +} + +/** + * The single element to append inside an existing `plugins: [...]`. + * + * The element alone, not the whole `plugins:` property. A reader who already + * has plugins configured — the likely one, since the directory is reached from + * the installed list — cannot use a property: pasting it inside the array is + * not valid TypeScript, and pasting it over the existing property silently + * drops every plugin already there. + * + * `callArgs` decides whether the binding is called at all: `null` means the + * package exports a ready-made plugin value, which is how + * `@nextlyhq/plugin-form-builder` ships one — its factory returns a result + * object whose definition sits at `.plugin`, so calling it here would be + * wrong rather than merely verbose. + */ +/** + * The side-effect import the app's admin route needs, for plugins that ship + * an `/admin` module — `undefined` for those that do not. + * + * Its own line rather than part of the config recipe because it goes in a + * different file: the admin route page, not `nextly.config.ts`. Undefined + * rather than an empty string, so a caller has to decide whether to render a + * step at all instead of rendering a blank one. + */ +export function adminImportStatement( + plugin: RegistryPlugin +): string | undefined { + return plugin.config.adminModule ? `import "${plugin.id}/admin";` : undefined; +} + +/** + * The stylesheet import the admin route needs, for plugins that ship one. + * + * A separate line from `adminImportStatement`, because the module and the + * stylesheet are separate imports in the same file and a plugin can ship + * either without the other. Taking only the module leaves the editor + * registered and unstyled, which looks like a broken build rather than a + * missing step. + */ +export function adminStylesImport(plugin: RegistryPlugin): string | undefined { + const subpath = plugin.config.adminStyles; + return subpath ? `import "${plugin.id}/${subpath}";` : undefined; +} + +export function pluginsArrayEntry(plugin: RegistryPlugin): string { + const { exportName, callArgs } = plugin.config; + return callArgs === null ? exportName : `${exportName}(${callArgs})`; +} diff --git a/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts b/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts index ba590eb3bd..adeee6bdec 100644 --- a/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts +++ b/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts @@ -43,6 +43,21 @@ function catalogueAppearance( }; } +/** + * The appearance sources for a catalogue entry, in precedence order. + * + * Exported so a component rendering the icon takes the ordering from here + * instead of writing `[installed, entry]` itself. That second spelling would be + * a second answer to which source wins, and the two would agree until one of + * them gained a step. + */ +export function cataloguePresentationCandidates( + entry: RegistryPlugin, + installed: Pick | undefined +): readonly (Pick | undefined)[] { + return [installed, catalogueAppearance(entry)]; +} + export interface CataloguePresentation { icon: PluginIconSource; description: string; @@ -66,13 +81,16 @@ export function resolveCataloguePresentation( opts: { allowAsset?: boolean } = {} ): CataloguePresentation { return { - icon: resolvePluginIconFrom([installed, catalogueAppearance(entry)], { - // Unreachable in practice: `catalogueAppearance` always carries a lucide - // name, since `RegistryPlugin.icon.lucide` is required. Named rather - // than asserted so the chain has a total answer if that ever loosens. - fallback: entry.icon.lucide, - allowAsset: opts.allowAsset, - }), + icon: resolvePluginIconFrom( + cataloguePresentationCandidates(entry, installed), + { + // Unreachable in practice: `catalogueAppearance` always carries a lucide + // name, since `RegistryPlugin.icon.lucide` is required. Named rather + // than asserted so the chain has a total answer if that ever loosens. + fallback: entry.icon.lucide, + allowAsset: opts.allowAsset, + } + ), // An installed plugin declaring an empty description says nothing, so it // does not get to blank the catalogue's text. description: installed?.description?.trim() diff --git a/packages/admin/src/lib/plugins/registry/types.ts b/packages/admin/src/lib/plugins/registry/types.ts index d70fe37f53..e1c0142a15 100644 --- a/packages/admin/src/lib/plugins/registry/types.ts +++ b/packages/admin/src/lib/plugins/registry/types.ts @@ -1,3 +1,5 @@ +import type { AdminIconName } from "@admin/components/icons"; + import type { PluginCategory } from "../plugin-categories"; /** @@ -22,16 +24,59 @@ export interface RegistryPlugin { author: string; category: PluginCategory; tags?: string[]; - icon: { lucide: string; asset?: string }; /** - * The line to add inside `plugins: [...]` in nextly.config.ts. + * `lucide` is checked against the icon barrel, unlike a plugin's own + * `appearance.icon`: this catalogue is ours, so a name nothing exports is a + * mistake we can refuse at compile time rather than a third party's string + * we have to tolerate at runtime. + */ + icon: { lucide: AdminIconName; asset?: string }; + /** + * What the reader has to write in nextly.config.ts, in the two parts they + * write it in: an import at the top of the file and an entry in the plugins + * array. * - * No package name beside it: that is `id`, and storing it twice means a - * rename can update one and leave the other, so the detail page would join - * on the new name while the install command still fetched the old one. - * Derive the command with `installCommand()`. + * Held as the binding and its arguments rather than as finished lines, + * because the two mention the same identifier and a stored pair can + * disagree — an entry naming a symbol the import does not bring in is a + * recipe that does not compile. Neither part repeats the package name + * either; that is `id`, and `importStatement()` reads it from there so a + * rename cannot leave the import fetching the old package while the detail + * page joins installed state on the new one. */ - configSnippet: string; + config: { + /** The binding the package exports and the plugins array references. */ + exportName: string; + /** + * The arguments as written inside the call: `""` for a call that takes + * none, or `null` when the export goes into the array uncalled. The two + * are different facts about the package's API, not two spellings of one. + */ + callArgs: string | null; + /** + * Whether the package ships an `/admin` side-effect module that the app's + * admin route has to import. + * + * A fourth edit, in a different file, and omitting it is not a small + * miss: the plugin installs and its server half runs, so nothing errors, + * while its admin UI silently never registers — the form builder degrades + * to plain JSON inputs. Declared rather than assumed, because it is a fact + * about the package's export map that only some plugins have. + */ + adminModule?: boolean; + /** + * The package subpath of a stylesheet the admin route must import, when + * the plugin ships one. + * + * Separate from `adminModule` because they are separate facts: the + * page builder's `/admin` entry registers components and imports no CSS, + * so an app that takes only the module gets a registered editor with none + * of its layout — the shell, grid, toolbar and panes all come from + * `styles/editor.css`. The subpath is stored rather than assumed, since + * only some packages export one and they need not agree on its name. + */ + adminStyles?: string; + }; links?: { homepage?: string; repository?: string; docs?: string }; } diff --git a/packages/admin/src/lib/plugins/resolve-plugin-icon.ts b/packages/admin/src/lib/plugins/resolve-plugin-icon.ts index 1d1ca5a2cf..cf75bb9b1e 100644 --- a/packages/admin/src/lib/plugins/resolve-plugin-icon.ts +++ b/packages/admin/src/lib/plugins/resolve-plugin-icon.ts @@ -39,20 +39,36 @@ export type PluginIconSource = */ export function resolvePluginIconFrom( candidates: readonly (Pick | undefined)[], - opts: { fallback: string; allowAsset: false } + opts: { + fallback: string; + allowAsset: false; + skipAssets?: ReadonlySet; + } ): Extract; export function resolvePluginIconFrom( candidates: readonly (Pick | undefined)[], - opts: { fallback: string; allowAsset?: boolean } + opts: { + fallback: string; + allowAsset?: boolean; + skipAssets?: ReadonlySet; + } ): PluginIconSource; export function resolvePluginIconFrom( candidates: readonly (Pick | undefined)[], - opts: { fallback: string; allowAsset?: boolean } + opts: { + fallback: string; + allowAsset?: boolean; + skipAssets?: ReadonlySet; + } ): PluginIconSource { for (const candidate of candidates) { const asset = candidate?.appearance?.iconAsset; - if (asset && opts.allowAsset !== false) + // A skipped asset drops only that candidate's image; the candidate's own + // glyph is still preferred over the next candidate, because a plugin + // naming a glyph beside a broken logo has said what to show instead. + if (asset && opts.allowAsset !== false && !opts.skipAssets?.has(asset)) { return { kind: "asset", src: asset }; + } const name = candidate?.appearance?.icon; if (name) return { kind: "lucide", name }; diff --git a/packages/admin/src/lib/routing.ts b/packages/admin/src/lib/routing.ts index 2739cc2182..37b1facf31 100644 --- a/packages/admin/src/lib/routing.ts +++ b/packages/admin/src/lib/routing.ts @@ -8,6 +8,24 @@ import registry, { routeConfig } from "../pages/registry"; import { matchPluginPage } from "./plugins/plugin-route-registry"; +/** + * Whether `pathname` is `base` or sits beneath it. + * + * Whole segments, so `/admin/plugins` does not claim `/admin/plugins-archive` + * — the reason this exists rather than each caller writing `includes()`. A + * substring test is a proxy for "is this route under that one", and the + * sibling paths that motivate writing the check are exactly the ones the proxy + * gets wrong. + * + * Callers pass `ROUTES.*` rather than a spelling, so a route that moves takes + * its classification with it. + */ +export function isUnder(pathname: string, base: string): boolean { + const path = pathname.replace(/\/$/, ""); + const target = base.replace(/\/$/, ""); + return path === target || path.startsWith(`${target}/`); +} + /** Props passed to page components by the router */ export interface PageProps { params?: Record; diff --git a/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx b/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx index 8aa87c8729..c59dc18afb 100644 --- a/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx +++ b/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx @@ -12,6 +12,9 @@ import { renderWithProviders } from "../../../__tests__/utils"; let mockBranding: AdminBranding | undefined; vi.mock("@admin/context/providers/BrandingProvider", () => ({ useBranding: () => mockBranding, + // Settled with an answer: every case here is about a plugin the branding + // payload already carries. + useBrandingStatus: () => ({ isPending: false, isUnavailable: false }), })); import PluginDetailPage from "./[slug]"; diff --git a/packages/admin/src/pages/dashboard/plugins/[slug].tsx b/packages/admin/src/pages/dashboard/plugins/[slug].tsx index eec6eba8fd..67a4045e12 100644 --- a/packages/admin/src/pages/dashboard/plugins/[slug].tsx +++ b/packages/admin/src/pages/dashboard/plugins/[slug].tsx @@ -1,6 +1,8 @@ "use client"; import { Badge } from "@nextlyhq/ui"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { Suspense } from "react"; import { BookOpen, @@ -18,16 +20,25 @@ import { } from "@admin/components/icons"; import { PageContainer } from "@admin/components/layout/page-container"; import { Breadcrumbs } from "@admin/components/shared"; -import { PageErrorFallback } from "@admin/components/shared/error-fallbacks"; +import { + PageErrorFallback, + SectionErrorFallback, +} from "@admin/components/shared/error-fallbacks"; import { PluginIcon } from "@admin/components/shared/plugin-icon"; import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary"; import { Link } from "@admin/components/ui/link"; import { ROUTES, buildRoute } from "@admin/constants/routes"; -import { useBranding } from "@admin/context/providers/BrandingProvider"; +import { + useBranding, + useBrandingStatus, +} from "@admin/context/providers/BrandingProvider"; import { categoryLabel } from "@admin/lib/plugins/plugin-categories"; import { pluginSlug } from "@admin/lib/plugins/plugin-slug"; +import { staticRegistrySource } from "@admin/lib/plugins/registry/static-source"; import type { PluginMetadata } from "@admin/types/branding"; +import { NotInstalledPlugin } from "./components/NotInstalledPlugin"; +import { PluginPageLoading } from "./components/PluginPageLoading"; import { PluginStatusPill } from "./components/PluginsTable"; const PLACEMENT_LABELS: Record = { @@ -66,35 +77,116 @@ export default function PluginDetailPage({ ); } -function PluginDetailContent({ activeSlug }: { activeSlug?: string }) { - const branding = useBranding(); - const plugins = branding?.plugins ?? []; - const plugin = activeSlug - ? plugins.find(p => pluginSlug(p.name) === activeSlug) +/** + * A slug the project has no plugin for. + * + * Two outcomes, and they are different facts: the catalogue knows this package + * and the reader has simply not installed it, or nothing knows it at all. The + * first is the ordinary path from the directory, where most entries are not + * installed, so it must not be reported as an error. + * + * Its own component because it queries the catalogue. Held inside + * `PluginDetailContent` the query would run for every installed plugin too, + * making a `QueryClientProvider` a requirement of rendering a page that has no + * need of one. + */ +function UninstalledOrMissing({ activeSlug }: { activeSlug?: string }) { + const { data: entries } = useSuspenseQuery({ + queryKey: ["plugin-registry"], + queryFn: () => staticRegistrySource.list(), + }); + const entry = activeSlug + ? entries.find(e => pluginSlug(e.id) === activeSlug) : undefined; - if (!plugin) { + if (entry) { return (
-
- -

- Plugin not found -

-

- No installed plugin matches this address. It may have been removed - from your Nextly config. -

-
+ +
+ ); + } + + return ( +
+ +
+ +

+ Plugin not found +

+

+ No installed plugin matches this address, and it is not in the plugin + directory either. It may have been removed from your Nextly config. +

+
+ ); +} + +/** + * Shown when admin-meta failed, so whether this plugin is installed is + * unknown. + * + * Not the catalogue view: that one states the plugin is absent, which is a + * claim this page cannot make when the request that would have told it failed. + */ +function InstalledPluginsUnavailable() { + const queryClient = useQueryClient(); + + return ( + { + void queryClient.invalidateQueries({ queryKey: ["admin-meta"] }); + }} + /> + ); +} + +function PluginDetailContent({ activeSlug }: { activeSlug?: string }) { + const branding = useBranding(); + const { isPending, isUnavailable } = useBrandingStatus(); + const plugins = branding?.plugins ?? []; + const plugin = activeSlug + ? plugins.find(p => pluginSlug(p.name) === activeSlug) + : undefined; + + // Installed metadata is observed; the catalogue is a claim, so the observed + // one decides. Only when the project has no such plugin is the catalogue + // consulted, and that lookup lives inside `UninstalledOrMissing` so its query + // is not a dependency of rendering an installed plugin. + // + // Absence is only a fact once admin-meta has answered. Until then the list is + // empty for a reason that says nothing about the project, and reading it as + // "not installed" tells someone who HAS this plugin to go and install it. + if (!plugin) { + if (isPending) return ; + if (isUnavailable) return ; + // Its own Suspense boundary: `UninstalledOrMissing` suspends on the + // catalogue, and the nearest boundary above is RootLayout's + // `fallback={null}`, which would blank the page for the duration. + return ( + }> + + ); } diff --git a/packages/admin/src/pages/dashboard/plugins/browse.test.tsx b/packages/admin/src/pages/dashboard/plugins/browse.test.tsx new file mode 100644 index 0000000000..7e5099ffe0 --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/browse.test.tsx @@ -0,0 +1,167 @@ +/** + * The directory joins two sources: a curated catalogue, and what the server + * reports as installed. These cover that join, and the route that reaches it — + * the directory sits outside `/admin/plugins/` so that no plugin name can + * shadow it, and that separation is worth pinning rather than assuming. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ROUTES } from "@admin/constants/routes"; +import { resolveRoute } from "@admin/lib/routing"; +import type { AdminBranding } from "@admin/types/branding"; + +let mockBranding: AdminBranding = { plugins: [] } as unknown as AdminBranding; + +vi.mock("@admin/lib/api/publicApi", () => ({ + publicApi: { get: () => Promise.resolve(mockBranding) }, +})); + +import PluginBrowsePage from "./browse"; + +function renderBrowse() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +afterEach(() => { + mockBranding = { plugins: [] } as unknown as AdminBranding; + vi.restoreAllMocks(); +}); + +describe("plugin browse route", () => { + it("resolves the directory path to the browse page", () => { + const resolved = resolveRoute(ROUTES.PLUGIN_BROWSE, ""); + + expect(resolved.Component).toBe(PluginBrowsePage); + expect(resolved.params).toEqual({}); + }); + + it("still resolves a real plugin slug to the detail page", () => { + // Positive control on the matcher: the dynamic route is reachable, so the + // assertion above is about the directory's own path rather than about a + // detail route that stopped matching anything. + const resolved = resolveRoute("/admin/plugins/nextlyhq-plugin-seo", ""); + + expect(resolved.Component).not.toBe(PluginBrowsePage); + expect(resolved.params).toEqual({ slug: "nextlyhq-plugin-seo" }); + }); + + /** + * The separating case, and the reason the directory does not live at + * `/admin/plugins/browse`. + * + * `PluginDefinition.name` is an arbitrary string, so a plugin can slugify to + * any segment a static sibling might occupy — `browse` included. With the + * two sharing a parent, one of them has to win, and the loser is a page the + * UI still links to and nothing can reach. Under a different parent the + * question does not arise, so this asserts the slug the old layout would + * have swallowed still reaches its own page. + */ + it("reaches the detail page for a plugin whose slug is the old directory segment", () => { + const resolved = resolveRoute("/admin/plugins/browse", ""); + + expect(resolved.Component).not.toBe(PluginBrowsePage); + expect(resolved.params).toEqual({ slug: "browse" }); + }); +}); + +describe("PluginBrowsePage", () => { + it("marks a catalogue entry installed when admin-meta reports it", async () => { + mockBranding = { + plugins: [{ name: "@nextlyhq/plugin-page-builder" }], + } as unknown as AdminBranding; + + renderBrowse(); + + expect(await screen.findAllByText("Page Builder")).not.toHaveLength(0); + expect( + screen.getAllByTestId("installed-@nextlyhq/plugin-page-builder").length + ).toBeGreaterThan(0); + }); + + /** + * Required alongside the case above: on its own, the positive test passes on + * a page that marks everything installed, and this one passes on a page that + * renders nothing. Only the pair separates them, which is why each asserts + * the entry IS rendered before asserting what it is not. + */ + it("does not mark it installed when admin-meta does not report it", async () => { + mockBranding = { plugins: [] } as unknown as AdminBranding; + + renderBrowse(); + + expect(await screen.findAllByText("Page Builder")).not.toHaveLength(0); + expect( + screen.queryByTestId("installed-@nextlyhq/plugin-page-builder") + ).toBeNull(); + }); + + /** + * Search reads what the card renders, not what the catalogue stores. An + * installed plugin's own description is what a reader sees, so typing a word + * from it must keep the card rather than filter it away. + */ + it("finds a card by the installed description it actually shows", async () => { + mockBranding = { + plugins: [ + { + name: "@nextlyhq/plugin-seo", + description: "Zebra crossing metadata", + }, + ], + } as unknown as AdminBranding; + + renderBrowse(); + await screen.findByText("Zebra crossing metadata"); + + fireEvent.change(screen.getByPlaceholderText("Search the directory"), { + target: { value: "zebra" }, + }); + + // Waited for, not asserted immediately: `SearchBar` debounces by 300ms, so + // a synchronous check runs before the query reaches this page and passes + // whatever the search would have done. + // + // The disappearance is the control. Asserting only that SEO survives is + // satisfied by a page that never filtered at all, which is the same green + // a search reading the wrong field produces. + // The timeout is sized to the debounce, not to taste. `waitFor` defaults + // to 1000ms, which is only ~3x a delay the component takes deliberately, + // and under a full parallel suite that margin is not enough — this passed + // alone and failed in the whole run. The property being asserted is + // unchanged; only the patience is. + await waitFor( + () => + expect( + screen.queryByRole("heading", { name: "Page Builder" }) + ).toBeNull(), + { timeout: 5000 } + ); + expect(screen.getByRole("heading", { name: "SEO" })).toBeInTheDocument(); + }); + + it("prefers an installed plugin's own description over the catalogue's", async () => { + mockBranding = { + plugins: [ + { + name: "@nextlyhq/plugin-seo", + description: "What the installed plugin says about itself", + }, + ], + } as unknown as AdminBranding; + + renderBrowse(); + + expect( + await screen.findByText("What the installed plugin says about itself") + ).toBeInTheDocument(); + }); +}); diff --git a/packages/admin/src/pages/dashboard/plugins/browse.tsx b/packages/admin/src/pages/dashboard/plugins/browse.tsx new file mode 100644 index 0000000000..00c7872567 --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/browse.tsx @@ -0,0 +1,219 @@ +"use client"; + +/** + * Plugin Directory + * + * Browse the plugins Nextly publishes, see which are already installed, and + * get the commands to add one. Discovery only: installing a plugin means + * adding a dependency and a line to `nextly.config.ts`, so this page never + * writes to a project's source and never mutates plugin state. + * + * @module pages/dashboard/plugins/browse + */ + +import { useSuspenseQuery } from "@tanstack/react-query"; +import type React from "react"; +import { Suspense, useMemo, useState } from "react"; + +import { PageContainer } from "@admin/components/layout/page-container"; +import { Breadcrumbs } from "@admin/components/shared"; +import { PageErrorFallback } from "@admin/components/shared/error-fallbacks"; +import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary"; +import { SearchBar } from "@admin/components/shared/search-bar"; +import { ROUTES } from "@admin/constants/routes"; +import { publicApi } from "@admin/lib/api/publicApi"; +import { resolveCataloguePresentation } from "@admin/lib/plugins/registry/resolve-catalogue-presentation"; +import { + shouldShowFeatured, + staticRegistrySource, +} from "@admin/lib/plugins/registry/static-source"; +import type { RegistryPlugin } from "@admin/lib/plugins/registry/types"; +import type { AdminBranding, PluginMetadata } from "@admin/types/branding"; + +import { PluginCard } from "./components/PluginCard"; + +/** + * Name, the RENDERED description, and tags. + * + * The description comes from the same resolution the card renders, so an + * installed plugin is searched by the text a reader can actually see. Reading + * `plugin.description` here instead would search the catalogue's copy while + * the card shows the plugin's own, and typing a word visibly on screen would + * make that card disappear. + * + * Author is deliberately not searched: every first-party entry shares one, so + * it would match the whole catalogue. + */ +function matches( + plugin: RegistryPlugin, + installed: Pick | undefined, + query: string +): boolean { + const { description } = resolveCataloguePresentation(plugin, installed); + const haystack = [plugin.name, description, ...(plugin.tags ?? [])] + .join(" ") + .toLowerCase(); + return haystack.includes(query); +} + +function PluginGrid({ + plugins, + installedByName, +}: { + plugins: RegistryPlugin[]; + installedByName: Map; +}): React.ReactElement { + return ( +
+ {plugins.map(plugin => ( + + ))} +
+ ); +} + +function BrowseContent(): React.ReactElement { + const [query, setQuery] = useState(""); + + const { data: branding } = useSuspenseQuery({ + queryKey: ["admin-meta"], + queryFn: () => publicApi.get("/admin-meta"), + staleTime: 5 * 60 * 1000, + }); + const { data: entries } = useSuspenseQuery({ + queryKey: ["plugin-registry"], + queryFn: () => staticRegistrySource.list(), + }); + const { data: featuredIds } = useSuspenseQuery({ + queryKey: ["plugin-registry-featured"], + queryFn: () => staticRegistrySource.featured(), + }); + + // Installed truth comes from admin-meta and nothing else. An entry absent + // from it is not installed; an installed plugin absent from the catalogue is + // private or third-party and is correctly not listed here. + const installedByName = useMemo(() => { + const map = new Map(); + for (const plugin of branding?.plugins ?? []) map.set(plugin.name, plugin); + return map; + }, [branding?.plugins]); + + const normalized = query.trim().toLowerCase(); + const visible = useMemo( + () => + normalized + ? entries.filter(e => matches(e, installedByName.get(e.id), normalized)) + : entries, + [entries, installedByName, normalized] + ); + + const featured = useMemo( + () => + featuredIds + .map(id => entries.find(e => e.id === id)) + .filter((e): e is RegistryPlugin => e !== undefined), + [entries, featuredIds] + ); + + // The strip is a recommendation, so it is hidden while searching: a filtered + // grid is the user's own list, and a fixed "start here" row above it answers + // a question they stopped asking. + const showFeatured = !normalized && shouldShowFeatured(entries, featuredIds); + + // The grid holds what the strip does not. Showing every entry below a strip + // that repeats two of them puts the same card on screen twice, which reads + // as a rendering fault rather than as a recommendation. + const featuredIdSet = useMemo( + () => new Set(showFeatured ? featuredIds : []), + [showFeatured, featuredIds] + ); + const rest = useMemo( + () => visible.filter(e => !featuredIdSet.has(e.id)), + [visible, featuredIdSet] + ); + + return ( + <> +
+ +
+ + {showFeatured && ( +
+ + +
+ )} + +
+

+ {showFeatured ? "More plugins" : "Plugins"} +

+ {rest.length > 0 ? ( + + ) : ( + // Two different nothings. A search that matched nothing names the + // term, because the reader chose it and can edit it; an empty + // catalogue must not, since quoting an empty string reads as a bug. +

+ {normalized + ? `No plugins match “${query.trim()}”.` + : "No plugins to show yet."} +

+ )} +
+ + ); +} + +const PluginBrowsePage: React.FC = () => ( + }> + + + +
+

Browse plugins

+

+ Plugins published by Nextly. Adding one is a dependency and a line in + your Nextly config, so open a plugin to get both. +

+
+ + +
+
+); + +export default PluginBrowsePage; diff --git a/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx b/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx new file mode 100644 index 0000000000..7415ef686b --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { Button } from "@nextlyhq/ui"; +import type React from "react"; +import { useState } from "react"; + +import { AlertCircle, Check, Copy } from "@admin/components/icons"; +import { PluginIcon } from "@admin/components/shared/plugin-icon"; +import { adminVersion } from "@admin/lib/admin-version"; +import { categoryLabel } from "@admin/lib/plugins/plugin-categories"; +import { + PACKAGE_MANAGERS, + adminImportStatement, + adminStylesImport, + importStatement, + installCommand, + pluginsArrayEntry, + type PackageManager, +} from "@admin/lib/plugins/registry/install-command"; +import type { RegistryPlugin } from "@admin/lib/plugins/registry/types"; + +/** + * A copyable command line. + * + * The confirmation lives on the button rather than in a toast: the reader is + * looking at the thing they clicked, and a toast in the corner asks them to + * look somewhere else to learn that something local succeeded. + */ +function CopyLine({ + value, + label, + file, +}: { + value: string; + label: string; + /** The file this line goes in, when it is not the one the section names. */ + file?: string; +}) { + const [outcome, setOutcome] = useState<"idle" | "copied" | "failed">("idle"); + + // The Clipboard API needs a secure context, so an admin served over plain + // HTTP — a LAN host, a colleague's dev box — has no `navigator.clipboard` at + // all, and `writeText` can be rejected by permissions even where it exists. + // Both end with the reader having to select the line by hand, so both have + // to say so: a button that silently does nothing reads as a broken page. + const copy = () => { + const clipboard = navigator.clipboard; + if (!clipboard) { + setOutcome("failed"); + return; + } + try { + void clipboard.writeText(value).then( + () => { + setOutcome("copied"); + window.setTimeout(() => setOutcome("idle"), 2000); + }, + () => setOutcome("failed") + ); + } catch { + // A synchronous throw rather than a rejection, which some + // implementations do when the document is not focused. + setOutcome("failed"); + } + }; + + return ( +
+

+ {label} + {file && ( + + {file} + + )} +

+
+ + {value} + + +
+ {outcome === "failed" && ( + // Announced, because the only other signal is an icon on the button + // the reader just pressed, and the instruction matters more than the + // failure: the line above is still there to be selected. +

+ Could not copy to the clipboard. Select the line above and copy it + manually. +

+ )} + {outcome === "copied" && ( + // Success is announced too, and only to assistive technology. The + // button's `aria-label` overrides its descendants, so swapping the + // glyph changes nothing a screen reader can perceive — sighted users + // already have the tick, and this is the equivalent for everyone else. +

+ {label} copied to the clipboard. +

+ )} +
+ ); +} + +/** + * What a catalogue plugin's page shows when the project has not installed it. + * + * Deliberately thin, and the reason is the invariant the whole surface is + * built on: verified content only ever appears in the verified section. Nothing + * here has been observed running, so there is no contributions section, no + * permissions and no routes — only what the catalogue claims, plus the lines + * that would make the claims checkable. + * + * @module pages/dashboard/plugins/components/NotInstalledPlugin + */ +export function NotInstalledPlugin({ + plugin, +}: { + plugin: RegistryPlugin; +}): React.ReactElement { + const [manager, setManager] = useState("pnpm"); + const label = categoryLabel(plugin.category); + const adminImport = adminImportStatement(plugin); + const adminStyles = adminStylesImport(plugin); + + return ( +
+
+ + + +
+

+ {plugin.name} +

+

+ {plugin.author} + {label ? ` · ${label}` : ""} +

+
+
+ +

+ {plugin.description} +

+ +
+
+

Add it to your project

+

+ Install the package, then make two edits in{" "} + nextly.config.ts: import the + plugin at the top of the file, and add it to the{" "} + plugins array. Nextly picks it up + on the next start. +

+
+ +
+ {PACKAGE_MANAGERS.map(pm => ( + + ))} +
+ + {/* One line per edit, each copyable on its own: the import and the + array entry land in different places in the same file, so joining + them into one block would be a snippet nobody can paste anywhere. */} + + + + {(adminImport || adminStyles) && ( +
+

+ This plugin also ships admin UI, registered by importing it in + your admin route page. Skip these and the plugin still loads — its + editors just fall back to plain inputs + {adminStyles ? ", and render unstyled" : ""}. +

+ {adminImport && ( + + )} + {adminStyles && ( + + )} +
+ )} +
+ + {/* States the boundary rather than leaving it implied: a reader looking + for permissions and routes should learn why they are absent instead + of concluding the plugin contributes nothing. */} +

+ What this plugin adds — its collections, permissions and API routes — is + only known once it is installed and Nextly has loaded it. +

+ + {plugin.links && ( +
+ {plugin.links.homepage && ( + + )} + {plugin.links.repository && ( + + )} + {plugin.links.docs && ( + + )} +
+ )} +
+ ); +} diff --git a/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx b/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx new file mode 100644 index 0000000000..733f7713d7 --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx @@ -0,0 +1,95 @@ +import { Badge } from "@nextlyhq/ui"; +import type React from "react"; + +import { PluginIconFrom } from "@admin/components/shared/plugin-icon"; +import { Link } from "@admin/components/ui/link"; +import { ROUTES, buildRoute } from "@admin/constants/routes"; +import { categoryLabel } from "@admin/lib/plugins/plugin-categories"; +import { pluginSlug } from "@admin/lib/plugins/plugin-slug"; +import { + cataloguePresentationCandidates, + resolveCataloguePresentation, +} from "@admin/lib/plugins/registry/resolve-catalogue-presentation"; +import type { RegistryPlugin } from "@admin/lib/plugins/registry/types"; +import type { PluginMetadata } from "@admin/types/branding"; + +interface PluginCardProps { + plugin: RegistryPlugin; + /** + * The installed plugin this entry describes, when the project has it. + * + * The metadata itself rather than a boolean: an installed plugin is the + * authority on its own icon and description, so the card needs what it + * declared and not merely the fact that it exists. + */ + installed: Pick | undefined; +} + +/** + * One plugin in the directory grid. + * + * A link rather than a card with a button inside it: the whole card is one + * destination, so making the card the anchor gives a keyboard user one stop + * instead of a container they must enter to find the real control. + * + * @module pages/dashboard/plugins/components/PluginCard + */ +export function PluginCard({ + plugin, + installed, +}: PluginCardProps): React.ReactElement { + const presentation = resolveCataloguePresentation(plugin, installed); + const label = categoryLabel(plugin.category); + + return ( + +
+ + + + +
+ {/* `truncate` needs the min-w-0 above: a flex child defaults to + min-width:auto, which refuses to shrink below its content. */} +

{plugin.name}

+

+ {plugin.author} +

+
+ + {presentation.isInstalled && ( + + Installed + + )} +
+ + {/* Two lines, not a character count: a clamp adapts to the card's real + width, where a truncated string would cut at a different place on + every breakpoint. */} +

+ {presentation.description} +

+ + {label && ( + + {label} + + )} + + ); +} diff --git a/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx b/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx new file mode 100644 index 0000000000..c4ff443b8e --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type React from "react"; + +import { Loader2 } from "@admin/components/icons"; + +/** + * What a plugin page shows while the data it suspends on is in flight. + * + * A `Suspense` fallback is not optional for these pages. `QueryErrorBoundary` + * handles errors and nothing else, and the only boundary above it is + * `RootLayout`'s `fallback={null}` — which exists to hide a lazy chunk + * swapping in, not to stand in for a page. Without a local boundary a + * suspending page is a blank screen for as long as the request takes. + * + * @module pages/dashboard/plugins/components/PluginPageLoading + */ +export function PluginPageLoading({ + label = "Loading…", +}: { + label?: string; +}): React.ReactElement { + return ( +
+ + {label} +
+ ); +} diff --git a/packages/admin/src/pages/dashboard/plugins/index.tsx b/packages/admin/src/pages/dashboard/plugins/index.tsx index 95d4b9d55d..6523d462ac 100644 --- a/packages/admin/src/pages/dashboard/plugins/index.tsx +++ b/packages/admin/src/pages/dashboard/plugins/index.tsx @@ -10,6 +10,7 @@ * @module pages/dashboard/plugins */ +import { Button } from "@nextlyhq/ui"; import type React from "react"; import { Suspense } from "react"; @@ -17,6 +18,7 @@ import { PageContainer } from "@admin/components/layout/page-container"; import { Breadcrumbs } from "@admin/components/shared"; import { PageErrorFallback } from "@admin/components/shared/error-fallbacks"; import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary"; +import { Link } from "@admin/components/ui/link"; import { ROUTES } from "@admin/constants/routes"; import PluginsTable from "./components/PluginsTable"; @@ -52,6 +54,14 @@ const PluginsOverviewPage: React.FC = () => { whether it is enabled.

+ + {/* In the header rather than only in the empty state: a project that + already has plugins is the one most likely to want another, and an + action that appears only while the list is empty is unreachable + exactly then. */} + {/* Plugins table */} diff --git a/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx b/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx new file mode 100644 index 0000000000..3296510959 --- /dev/null +++ b/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx @@ -0,0 +1,291 @@ +/** + * A plugin's page has one URL whether or not the project has it. These cover + * the uninstalled half: the directory links every card here, and most cards + * are for plugins nobody has installed yet, so that is the ordinary path + * rather than an error. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AdminBranding } from "@admin/types/branding"; + +/** + * The install line, as a pattern shared by the assertions that require it and + * the ones that require its absence. + * + * A literal `"pnpm add @nextlyhq/plugin-seo"` would match nothing now that the + * command is pinned, so every `queryByText(...).toBeNull()` below would pass + * on a page that rendered the install block in full. Matching the version + * loosely keeps the absence assertions answering the question they ask, while + * `install-command.test.ts` pins the exact strings. + */ +const INSTALL_COMMAND = /^pnpm add @nextlyhq\/plugin-seo@\d/; + +/** + * jsdom's navigator has no `clipboard` property at all, which is the same + * shape a browser on a plain-HTTP origin presents — so the "unavailable" case + * below is the environment's own default rather than something simulated. + */ +const ORIGINAL_CLIPBOARD = Object.getOwnPropertyDescriptor( + navigator, + "clipboard" +); + +function setClipboard(clipboard: Clipboard | undefined) { + Object.defineProperty(navigator, "clipboard", { + value: clipboard, + configurable: true, + }); +} + +/** + * Puts back what was there, which in jsdom is no own property at all — + * defining one with `undefined` is not the same thing, and it would outlive + * these tests for anything else sharing this environment. + */ +function restoreClipboard() { + if (ORIGINAL_CLIPBOARD) { + Object.defineProperty(navigator, "clipboard", ORIGINAL_CLIPBOARD); + } else { + delete (navigator as { clipboard?: Clipboard }).clipboard; + } +} + +let mockBranding: AdminBranding = { plugins: [] } as unknown as AdminBranding; +let mockBrandingStatus = { isPending: false, isUnavailable: false }; + +vi.mock("@admin/context/providers/BrandingProvider", () => ({ + useBranding: () => mockBranding, + useBrandingStatus: () => mockBrandingStatus, +})); + +import PluginDetailPage from "./[slug]"; + +function renderDetail(slug: string) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +afterEach(() => { + mockBranding = { plugins: [] } as unknown as AdminBranding; + mockBrandingStatus = { isPending: false, isUnavailable: false }; + restoreClipboard(); + vi.restoreAllMocks(); +}); + +describe("plugin detail, not installed", () => { + /** + * The separating case. Before this view existed the page searched only + * `branding.plugins`, so every catalogue entry the directory linked to + * rendered "Plugin not found" — a dead end on the primary discovery path. + */ + it("shows the catalogue entry instead of a not-found page", async () => { + renderDetail("nextlyhq-plugin-seo"); + + expect( + await screen.findByRole("heading", { name: "SEO" }) + ).toBeInTheDocument(); + expect(screen.queryByText("Plugin not found")).toBeNull(); + }); + + /** + * All three lines, because the recipe only works as a set: installing the + * package does not introduce the binding, so an array entry shown without + * its import names an identifier that does not exist in the file. + */ + it("offers the install command, the import, and the array entry", async () => { + renderDetail("nextlyhq-plugin-seo"); + + expect(await screen.findByText(INSTALL_COMMAND)).toBeInTheDocument(); + expect( + screen.getByText('import { seoPlugin } from "@nextlyhq/plugin-seo";') + ).toBeInTheDocument(); + // The element alone, with no `plugins:` property around it. A reader who + // already has plugins configured appends this; a property could only be + // pasted by replacing theirs. + expect( + screen.getByText('seoPlugin({ collections: ["your-collection"] })') + ).toBeInTheDocument(); + }); + + /** + * A plugin the project HAS, on a page opened cold. Until admin-meta answers + * the installed list is empty for a reason that says nothing about the + * project, and reading that as "not installed" tells the reader to install + * something they already have. + */ + it("does not offer to install while the installed list is still loading", async () => { + mockBrandingStatus = { isPending: true, isUnavailable: false }; + + renderDetail("nextlyhq-plugin-seo"); + + expect(await screen.findByRole("status")).toBeInTheDocument(); + expect(screen.queryByText(INSTALL_COMMAND)).toBeNull(); + expect(screen.queryByText("Plugin not found")).toBeNull(); + }); + + /** Same reasoning, permanent: a failed request is not evidence of absence. */ + it("does not offer to install when the installed list could not be loaded", async () => { + mockBrandingStatus = { isPending: false, isUnavailable: true }; + + renderDetail("nextlyhq-plugin-seo"); + + expect( + await screen.findByText("Could not load your installed plugins") + ).toBeInTheDocument(); + expect(screen.queryByText(INSTALL_COMMAND)).toBeNull(); + }); + + /** + * The admin-module step, which only some plugins need. Form Builder ships an + * `/admin` side-effect module; skip it and the plugin installs, its server + * half runs, and its builder silently degrades to plain inputs. + */ + it("asks for the admin route import when the plugin ships one", async () => { + renderDetail("nextlyhq-plugin-form-builder"); + + expect( + await screen.findByText('import "@nextlyhq/plugin-form-builder/admin";') + ).toBeInTheDocument(); + }); + + /** + * Page Builder needs both the module and its stylesheet. Form Builder needs + * only the module, so a page that showed a stylesheet line for it would be + * naming a subpath that does not resolve. + */ + it("asks for the editor stylesheet only where the package exports one", async () => { + renderDetail("nextlyhq-plugin-page-builder"); + expect( + await screen.findByText( + 'import "@nextlyhq/plugin-page-builder/styles/editor.css";' + ) + ).toBeInTheDocument(); + + cleanup(); + + renderDetail("nextlyhq-plugin-form-builder"); + await screen.findByText('import "@nextlyhq/plugin-form-builder/admin";'); + expect(screen.queryByText("Editor stylesheet")).toBeNull(); + }); + + /** + * The separating case. SEO has no `/admin` export, so telling a reader to + * import one would name a subpath that does not resolve. + */ + it("omits the admin route import for a plugin without one", async () => { + renderDetail("nextlyhq-plugin-seo"); + + await screen.findByRole("heading", { name: "SEO" }); + expect(screen.queryByText(/\/admin";$/)).toBeNull(); + expect(screen.queryByText("Admin route import")).toBeNull(); + }); + + /** + * The catalogue query suspends. The only boundary above this page is + * `RootLayout`'s `fallback={null}`, which exists to hide a lazy chunk + * swapping in, so without a local one the page is blank for the duration of + * the request rather than merely slow. + */ + it("shows a loading state while the catalogue is in flight", () => { + renderDetail("nextlyhq-plugin-seo"); + + // Read synchronously, before the catalogue promise settles: this is the + // frame the missing boundary would have rendered as nothing. + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + /** + * The Clipboard API needs a secure context, so an admin on plain HTTP has no + * `navigator.clipboard` at all. Without this the button is inert and the + * page looks broken; the line is still selectable, so the fix is to say so. + */ + it("says copying failed when the clipboard is unavailable", async () => { + setClipboard(undefined); + + renderDetail("nextlyhq-plugin-seo"); + fireEvent.click( + await screen.findByRole("button", { name: /copy install command/i }) + ); + + expect(await screen.findByText(/could not copy/i)).toBeInTheDocument(); + }); + + it("says copying failed when the write is rejected", async () => { + setClipboard({ + writeText: () => Promise.reject(new Error("denied")), + } as unknown as Clipboard); + + renderDetail("nextlyhq-plugin-seo"); + fireEvent.click( + await screen.findByRole("button", { name: /copy install command/i }) + ); + + expect(await screen.findByText(/could not copy/i)).toBeInTheDocument(); + }); + + /** + * The positive control for the two above: a working clipboard must NOT show + * the failure text, or both would pass on a component that always shows it. + */ + it("stays quiet when the copy succeeds", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setClipboard({ writeText } as unknown as Clipboard); + + renderDetail("nextlyhq-plugin-seo"); + fireEvent.click( + await screen.findByRole("button", { name: /copy install command/i }) + ); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(screen.queryByText(/could not copy/i)).toBeNull(); + }); + + /** + * The invariant the whole surface rests on: verified content only ever + * appears in the verified section. Nothing here has been observed running, + * so the sections that report what a plugin contributes must be absent. + */ + it("reports no contributions, because none have been observed", async () => { + renderDetail("nextlyhq-plugin-seo"); + + await screen.findByRole("heading", { name: "SEO" }); + expect(screen.queryByText("Permissions")).toBeNull(); + expect(screen.queryByText("API routes")).toBeNull(); + }); + + it("still says not found for a slug no plugin and no entry matches", async () => { + renderDetail("no-such-plugin"); + + expect(await screen.findByText("Plugin not found")).toBeInTheDocument(); + }); + + /** + * Installed metadata is observed; the catalogue is a claim. When both + * describe the same package the observed one decides which view renders, + * otherwise installing a plugin would leave it looking uninstalled. + */ + it("prefers the installed plugin over the catalogue entry", async () => { + mockBranding = { + plugins: [{ name: "@nextlyhq/plugin-seo", version: "1.0.0" }], + } as unknown as AdminBranding; + + renderDetail("nextlyhq-plugin-seo"); + + expect(await screen.findByText("v1.0.0")).toBeInTheDocument(); + expect(screen.queryByText(INSTALL_COMMAND)).toBeNull(); + }); +}); diff --git a/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx b/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx index c669474b44..69bfc7f0f5 100644 --- a/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx +++ b/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; @@ -43,6 +44,8 @@ const plugins: PluginMetadata[] = [ vi.mock("@admin/context/providers/BrandingProvider", () => ({ useBranding: () => ({ plugins }), + // Settled with an answer, so a slug missing from `plugins` really is absent. + useBrandingStatus: () => ({ isPending: false, isUnavailable: false }), })); describe("PluginDetailPage", () => { @@ -101,8 +104,21 @@ describe("PluginDetailPage", () => { expect(screen.getByText(/its behavior does not load/i)).toBeInTheDocument(); }); - it("renders a not-found state for an unknown slug", () => { - render(); - expect(screen.getByText("Plugin not found")).toBeInTheDocument(); + /** + * Not found now means neither installed NOR in the plugin directory, so this + * path reads the catalogue and needs a query client. A slug that IS in the + * catalogue renders the uninstalled view instead, covered in + * `not-installed-detail.test.tsx`. + */ + it("renders a not-found state for a slug nothing knows", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + + ); + expect(await screen.findByText("Plugin not found")).toBeInTheDocument(); }); }); diff --git a/packages/admin/src/pages/registry.ts b/packages/admin/src/pages/registry.ts index 7bc48ffb8e..848f260b21 100644 --- a/packages/admin/src/pages/registry.ts +++ b/packages/admin/src/pages/registry.ts @@ -20,6 +20,7 @@ import DashboardPage from "./dashboard/index"; import MediaLibraryPage from "./dashboard/media/index"; import PluginDetailPage from "./dashboard/plugins/[slug]"; import PluginSettingsPage from "./dashboard/plugins/[slug]/settings"; +import PluginBrowsePage from "./dashboard/plugins/browse"; import PluginsOverviewPage from "./dashboard/plugins/index"; import CollectionsLandingRedirect from "./dashboard/redirects/CollectionsLandingRedirect"; import FieldGroupsLandingRedirect from "./dashboard/redirects/FieldGroupsLandingRedirect"; @@ -372,6 +373,14 @@ export const routeConfig: Record = { type: "private", requiredPermission: "manage-settings", }, + // Registered outside `/admin/plugins/`, so no ordering rule holds this in + // place: the directory and a plugin's detail page cannot match the same + // path, whatever the plugin is called. + [ROUTES.PLUGIN_BROWSE]: { + component: PluginBrowsePage, + type: "private", + requiredPermission: "manage-settings", + }, [ROUTES.PLUGIN_DETAIL]: { component: PluginDetailPage, type: "private", diff --git a/packages/admin/tsup.config.ts b/packages/admin/tsup.config.ts index 7659f286f6..2a117154c8 100644 --- a/packages/admin/tsup.config.ts +++ b/packages/admin/tsup.config.ts @@ -1,4 +1,5 @@ import fs from "fs"; +import { createRequire } from "module"; import path from "path"; import { fileURLToPath } from "url"; @@ -8,6 +9,13 @@ import { defineConfig } from "tsup"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// Read at build time so `adminVersion()` can name the release this bundle was +// published in. The browser bundle has no module resolver to read it later. +const require = createRequire(import.meta.url); +const { version: ADMIN_VERSION } = require("./package.json") as { + version: string; +}; + /** * Bundle size target in KB (minified, not gzipped) * Based on industry standards for comprehensive UI libraries: @@ -100,6 +108,9 @@ export default defineConfig(options => [ resolve: true, }, tsconfig: "tsconfig.json", + define: { + __NEXTLY_ADMIN_VERSION__: JSON.stringify(ADMIN_VERSION), + }, external: EXTERNAL_DEPS, noExternal: NO_EXTERNAL_DEPS, splitting: true, diff --git a/packages/admin/vitest.config.ts b/packages/admin/vitest.config.ts index 8853a7cd9b..e740b12f34 100644 --- a/packages/admin/vitest.config.ts +++ b/packages/admin/vitest.config.ts @@ -1,9 +1,20 @@ +import { createRequire } from "module"; + import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; import { defineConfig } from "vitest/config"; +// Mirror the tsup `define` so `adminVersion()` resolves under test. +const require = createRequire(import.meta.url); +const { version: adminVersion } = require("./package.json") as { + version: string; +}; + export default defineConfig({ plugins: [react(), tsconfigPaths()], + define: { + __NEXTLY_ADMIN_VERSION__: JSON.stringify(adminVersion), + }, test: { name: "admin", globals: true,