diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 1f7bf35e..e09571e6 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -34,7 +34,6 @@ "download": "Download", "noPreview": "This file type cannot be shown here — download it to open it.", "install": "Install", - "installs": "{{count}} installs", "installed": "Installed to {{path}}", "installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it", "rename": "Edit", @@ -197,5 +196,23 @@ "unowned.claimed": "Added to your library", "unowned.declined": "Removed from your list", "unowned.assigned": "Owner assigned", - "unowned.empty": "Nothing without an owner." + "unowned.empty": "Nothing without an owner.", + "installs_one": "{{count}} install", + "installs_other": "{{count}} installs", + "private": "Private", + "privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.", + "showMore": "Show {{count}} more", + "view.label": "Layout", + "view.list": "List view", + "view.grid": "Grid view", + "pack.title": "{{prefix}}-* pack", + "pack.count_one": "{{count}} skill", + "pack.count_other": "{{count}} skills", + "pack.installAll": "Install all", + "pack.installRest": "Install {{count}} more", + "pack.installTitle": "Install {{count}} skills of {{prefix}}-*?", + "pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:", + "pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}", + "section.unshared": "Not shared by their owners", + "section.unsharedHint": "Other users' private items — only admins see these" } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 4e7f0a50..5c4e2b57 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -197,5 +197,20 @@ "unowned.claimed": "Đã nhận về thư viện của bạn", "unowned.declined": "Đã bỏ khỏi danh sách của bạn", "unowned.assigned": "Đã gán chủ", - "unowned.empty": "Không có mục nào chưa có chủ." + "unowned.empty": "Không có mục nào chưa có chủ.", + "private": "Riêng tư", + "privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.", + "showMore": "Xem thêm {{count}}", + "view.label": "Cách hiển thị", + "view.list": "Dạng danh sách", + "view.grid": "Dạng lưới", + "pack.title": "Bộ {{prefix}}-*", + "pack.count": "{{count}} skill", + "pack.installAll": "Cài tất cả", + "pack.installRest": "Cài {{count}} còn lại", + "pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?", + "pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:", + "pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}", + "section.unshared": "Chủ chưa chia sẻ", + "section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy" } diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index ed00e09c..20e47c85 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -1,21 +1,28 @@ /** * @file Artifacts.tsx * @description `/artifacts` — the Artifacts hub (`/api/artifacts`, - * server/lib/artifacts.js), in the same shapes as the other pages (a plain - * title row, Sessions' filter bar, `.card` tiles, no decorative icons): a - * search and category filter, the unfiltered front page shelved by kind - * (Skills, Plugins, Pages, Files — "See all" picks the category), each - * artifact a card with one primary action (Install / Installed / Open / - * Preview / Download), text actions in its footer, the rest in its ⋯ menu, - * and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned + * server/lib/artifacts.js): scope tabs under the title (All · My library · + * Hub, server mode), a search, a category filter and a List/Grid switch (a + * per-browser preference), the unfiltered local page shelved by kind (Skills, + * Plugins, Pages, Files — "See all" picks the category). The default List + * view is one row per artifact — a kind icon, name, one line of description, + * who and how many installed it, ONE primary action (Install / Installed / + * Open / Preview / Download) and everything else (Preview, Share, Edit, + * Delete, Uninstall) in its ⋯ menu or a right-click; Grid shows the same as + * cards. Badges mark only the exception (Private, Hub only, Auto), never the + * shared default. Three or more skills of one owner whose names share a prefix + * (`sg-qa`, `sg-ship`, …) are one pack row that opens to its skills or + * installs the missing ones after one confirmation — read from the names + * only, nothing stored. A click on a row opens its detail sheet. It holds claude.ai pages sessions published, pinned * workspace files (or one uploaded from the computer), skills (picked, * uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and * plugins (read from a marketplace link, or typed). One list holds the - * caller's own rows and, in server mode, what every user shared; a small - * **All · My library · Hub** filter narrows it. In server mode every tab is - * split by who it is from, others' first (All: others' shared rows / yours; - * Hub: shared by others / by you; My library: installed from others / your - * own), and every section pages through 12 cards at a time. A shared skill or plugin is + * caller's own rows and, in server mode, what every user shared. In server + * mode every tab is split by who it is from, others' first (All: others' + * shared rows / yours; Hub: shared by others / by you; My library: installed + * from others / your own), with another user's PRIVATE row (an admin sees + * those) in a section of its own, never under "Shared by others"; a section + * shows 30 entries, then "Show more". A shared skill or plugin is * installed into the viewer's own account (after a confirmation that lists * what will be written or run); one already installed there shows * **Installed** instead of the Install button; **Uninstall** (a skill's @@ -41,7 +48,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { MoreHorizontal, Search, X } from "lucide-react"; +import { + ChevronRight, + FileText, + Globe, + LayoutGrid, + List, + MoreHorizontal, + Puzzle, + Search, + Sparkles, + X, +} from "lucide-react"; import { api, type Artifact, @@ -107,6 +125,13 @@ const segment = (on: boolean) => `rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${ on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary" }`; +// A scope tab under the title (All · My library · Hub). +const tabClass = (on: boolean) => + `-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors ${ + on + ? "border-accent-solid text-fg-primary" + : "border-transparent text-fg-muted hover:text-fg-secondary" + }`; const smallPrimary = "btn-primary shrink-0 px-3 py-1 text-xs"; const smallSecondary = "btn-secondary shrink-0 px-2.5 py-1 text-xs"; // A text action in a card's footer. @@ -130,6 +155,22 @@ export function Artifacts() { const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null); // By id, so the open sheet shows the row as the latest list has it. const [detailId, setDetailId] = useState(null); + // List or grid: a viewer preference, kept in this browser only. + const [view, setView] = useState(() => { + try { + return localStorage.getItem(VIEW_KEY) === "grid" ? "grid" : "list"; + } catch { + return "list"; + } + }); + const pickView = (v: View) => { + setView(v); + try { + localStorage.setItem(VIEW_KEY, v); + } catch { + /* private window: the choice lasts this visit */ + } + }; useEffect(() => { api.artifacts @@ -296,6 +337,37 @@ export function Artifacts() { load(); }; + // A pack's missing skills, after ONE confirmation that names them all; a + // skill already there under that name is left alone (never replaced here). + const installAll = async (p: Pack) => { + const todo = p.rows.filter((a) => !a.installed); + if ( + !(await confirmDialog({ + title: t("pack.installTitle", { prefix: p.prefix, count: todo.length }), + message: `${t("pack.installBody")} ${todo.map((a) => a.source || a.title).join(", ")}`, + confirmLabel: t("installConfirm"), + destructive: false, + })) + ) + return; + let ok = 0; + let skipped = 0; + let failed = 0; + for (const a of todo) { + try { + await api.artifacts.install(a.id); + ok++; + } catch (err) { + if ((err as { code?: string }).code === "EEXISTS") skipped++; + else failed++; + } + } + const summary = t("pack.installed", { ok, skipped, failed }); + if (failed) toast.error(summary); + else toast.success(summary); + load(); + }; + // Taken out of the user's own accounts again; a skill's copies are moved to // ~/.claude/agenthub-replaced-skills/, never deleted. const uninstall = async (a: Artifact) => { @@ -334,66 +406,91 @@ export function Artifacts() { }, }); - const card = (a: Artifact) => - tab === "unowned" ? ( -
- -
- {(a.suggestedForMe || isAdmin) && ( - - )} - {a.suggestedForMe && ( - - )} - {isAdmin && users.length > 0 && ( - - )} -
-
+ const unownedControls = (a: Artifact) => ( +
+ {(a.suggestedForMe || isAdmin) && ( + + )} + {a.suggestedForMe && ( + + )} + {isAdmin && users.length > 0 && ( + + )} +
+ ); + + // One artifact as a list row or a grid card; a pack's members are always rows. + const render = (a: Artifact, as: View, inPack = false) => { + const props = { + item: a, + server, + canInstall: !!settings?.installsEnabled, + ...actionsFor(a), + }; + if (tab === "unowned") + return ( +
  • + + {unownedControls(a)} +
  • + ); + return as === "list" ? ( + ) : ( - + ); + }; + const pack = (p: Pack) => ( + void installAll(p)} + render={(a) => render(a, "list", true)} + /> + ); + const collection = (rows: Artifact[], key: string) => ( + + ); // Local mode (everything is yours): the unfiltered page is shelved by - // kind; a category or a search is one flat grid of results. + // kind; a category or a search is one flat list of results. const shelves = items && !kind && !q.trim() ? SHELVES.map((k) => ({ kind: k, rows: items.filter((a) => a.kind === k) })).filter( @@ -403,19 +500,28 @@ export function Artifacts() { // In server mode every tab is split by who it is from, others' first: All // into others' shared rows and yours, the Hub into what others shared and // what you shared, My library into what you installed from others (the - // server adds those to `mine`) and your own. + // server adds those to `mine`) and your own. Another user's row that is NOT + // shared (only an admin is shown those) gets a section of its own, so + // "Shared by others" never holds a private row. const halves = server && items && tab !== "unowned" - ? (["others", "own"] as const).map((who) => ({ - key: `${tab}.${who}`, - rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)), - })) + ? [ + { + key: `${tab}.others`, + rows: items.filter((a) => !a.mine && (a.shared || tab === "mine")), + }, + { + key: "unshared", + rows: tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared), + }, + { key: `${tab}.own`, rows: items.filter((a) => a.mine) }, + ].filter((h) => h.rows.length > 0 || h.key !== "unshared") : null; const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null); return ( -
    -
    +
    +

    {t("title")}

    @@ -455,8 +561,40 @@ export function Artifacts() {

    -
    -
    + {server && ( +
    + {(["all", "mine", "hub"] as const).map((v) => ( + + ))} + {(isAdmin || unownedCount > 0 || tab === "unowned") && ( + + )} +
    + )} + +
    +
    @@ -493,35 +631,28 @@ export function Artifacts() { ))}
    - {server && ( + {tab !== "unowned" && (
    - {(["all", "mine", "hub"] as const).map((v) => ( - - ))} - {(isAdmin || unownedCount > 0 || tab === "unowned") && ( - - )} + {(["list", "grid"] as const).map((v) => { + const Icon = v === "list" ? List : LayoutGrid; + return ( + + ); + })}
    )}
    @@ -539,20 +670,19 @@ export function Artifacts() { onAdd={(k) => setAdding(k)} /> ) : halves ? ( -
    +
    {halves.map((h) => (
    -
    -

    - {t(`section.${h.key}`)} - {h.rows.length} -

    -

    {t(`section.${h.key}Hint`)}

    -
    + {h.rows.length ? ( - + collection(h.rows, `${h.key}|${kind}|${q}`) ) : ( -

    +

    {t("section.empty")}

    )} @@ -560,33 +690,32 @@ export function Artifacts() { ))}
    ) : shelves ? ( -
    +
    {shelves.map((s) => (
    -
    -
    -

    - {t(`kind.${s.kind}`)} - {s.rows.length} -

    -

    {t(`blurb.${s.kind}`)}

    -
    - -
    - + setKind(s.kind)} + className="btn-ghost shrink-0 px-2 py-1 text-xs" + > + {t("seeAll")} + + } + /> + {collection(s.rows, s.kind)}
    ))}
    ) : (

    {t("results", { count: items.length })}

    - + {collection(items, `${tab}|${kind}|${q}`)}
    )} @@ -615,57 +744,204 @@ export function Artifacts() { ); } -// Cards per page of a section: four rows of the three-column grid. -const PAGE_SIZE = 12; - -/** A section's cards, a page at a time once there are more than `PAGE_SIZE` - * (the Sessions table's Previous · n / m · Next). Keyed by the caller on the - * filters, so a new tab, kind or search starts again at page one. */ -function PagedGrid({ rows, card }: { rows: Artifact[]; card: (a: Artifact) => React.ReactNode }) { - const { t } = useTranslation("artifacts"); - const [page, setPage] = useState(0); - const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); - const at = Math.min(page, pages - 1); - const pager = - "rounded-md bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"; +function SectionHead({ + id, + title, + count, + hint, + action, +}: { + id: string; + title: string; + count: number; + hint: string; + action?: React.ReactNode; +}) { return ( - <> -
      - {rows.slice(at * PAGE_SIZE, (at + 1) * PAGE_SIZE).map(card)} -
    - {pages > 1 && ( - +
    +
    +

    + {title} + {count} +

    +

    {hint}

    +
    + {action} +
    + ); +} + +type View = "list" | "grid"; +const VIEW_KEY = "agenthub.artifacts.view"; + +// How many entries a section shows before "Show more". +const STEP = 30; +// A pack: this many or more skills of one owner whose names share a prefix +// (`sg-qa`, `sg-ship`, …) — one row that opens to its skills or installs them +// all. Read from the names only; nothing is stored about it. +const PACK_MIN = 3; +const packPrefix = (a: Artifact) => + a.kind === "skill" + ? (/^([a-z0-9]+)-[a-z0-9]/i.exec(a.source || a.title)?.[1]?.toLowerCase() ?? null) + : null; + +type Pack = { key: string; prefix: string; owner: string | null; mine: boolean; rows: Artifact[] }; +type Entry = { pack: Pack } | { item: Artifact }; + +/** The packs first, in the order their first skill came, then every other row. */ +function groupPacks(rows: Artifact[]): Entry[] { + const groups = new Map(); + for (const a of rows) { + const prefix = packPrefix(a); + if (!prefix) continue; + const key = `${a.ownerId ?? (a.mine ? "me" : a.owner)}|${prefix}`; + groups.set(key, [...(groups.get(key) ?? []), a]); + } + const packs: Entry[] = []; + const packed = new Set(); + for (const [key, members] of groups) { + if (members.length < PACK_MIN) continue; + members.forEach((a) => packed.add(a.id)); + const first = members[0]!; + packs.push({ + pack: { + key, + prefix: packPrefix(first)!, + owner: first.owner, + mine: first.mine, + rows: members, + }, + }); + } + return [...packs, ...rows.filter((a) => !packed.has(a.id)).map((item) => ({ item }))]; +} + +/** A section's packs and rows, `STEP` entries at a time (a pack counts as one). + * Keyed by the caller on the filters, so a new tab, kind or search starts over. */ +function Collection({ + rows, + view, + packs: withPacks, + render, + pack, +}: { + rows: Artifact[]; + view: View; + packs: boolean; + render: (a: Artifact, as: View) => React.ReactNode; + pack: (p: Pack) => React.ReactNode; +}) { + const { t } = useTranslation("artifacts"); + const [shown, setShown] = useState(STEP); + const entries: Entry[] = withPacks ? groupPacks(rows) : rows.map((item) => ({ item })); + const visible = entries.slice(0, shown); + const packList = visible.flatMap((e) => ("pack" in e ? [e.pack] : [])); + const items = visible.flatMap((e) => ("item" in e ? [e.item] : [])); + return ( +
    + {packList.length > 0 && ( +
      {packList.map(pack)}
    )} - + {items.length > 0 && + (view === "list" ? ( +
      + {items.map((a) => render(a, "list"))} +
    + ) : ( +
      + {items.map((a) => render(a, "grid"))} +
    + ))} + {entries.length > shown && ( + + )} +
    + ); +} + +/** A pack as one row: open it for its skills, or install the ones you lack. */ +function PackRow({ + pack: p, + server, + canInstall, + onInstallAll, + render, +}: { + pack: Pack; + server: boolean; + canInstall: boolean; + onInstallAll: () => void; + render: (a: Artifact) => React.ReactNode; +}) { + const { t } = useTranslation("artifacts"); + const [open, setOpen] = useState(false); + const missing = p.rows.filter((a) => !a.installed).length; + const installs = p.rows.reduce((n, a) => n + a.installs, 0); + return ( +
  • +
    + + + {[ + server && p.owner && !p.mine ? p.owner : null, + installs ? t("installs", { count: installs }) : null, + ] + .filter(Boolean) + .join(" · ")} + + {missing === 0 ? ( + + {t("installedBadge")} + + ) : ( + + )} +
    + {open && ( +
      + {p.rows.map(render)} +
    + )} +
  • ); } @@ -789,10 +1065,96 @@ function PrimaryAction({ return null; } -function ArtifactCard({ +type ItemProps = CardActions & { + item: Artifact; + server: boolean; + canInstall: boolean; +}; + +const KIND_ICON = { skill: Sparkles, plugin: Puzzle, page: Globe, file: FileText } as const; + +/** What kind a row is, at a glance: a tinted square with the kind's icon. */ +function KindIcon({ kind, pack }: { kind: ArtifactKind; pack?: boolean }) { + const { t } = useTranslation("artifacts"); + const Icon = KIND_ICON[kind]; + return ( + + + + ); +} + +/** Badges only for the exception: Private (server mode, not shared), Hub only, Auto. */ +function Badges({ item: a, server }: { item: Artifact; server: boolean }) { + const { t } = useTranslation("artifacts"); + const { hubOnly } = capabilities(a); + return ( + <> + {hubOnly && ( + + {t("hubOnly")} + + )} + {server && !a.shared && ( + + {t("private")} + + )} + {a.auto && ( + + {t("auto")} + + )} + + ); +} + +/** "by X · date · N installs" — who, when and how far it travelled. */ +function Meta({ + item: a, + server, + date = true, +}: { + item: Artifact; + server: boolean; + date?: boolean; +}) { + const parts: React.ReactNode[] = []; + if (server && a.owner && !a.mine) + parts.push( + + {a.owner} + + ); + if (date) + parts.push( + + {fmtDate(a.createdAt)} + + ); + if (a.installs > 0) parts.push(); + return ( + + {parts.flatMap((p, i) => (i ? [·, p] : [p]))} + + ); +} + +/** Every action an artifact offers — its ⋯ button and its right-click. */ +function ArtifactMenu({ item: a, server, canInstall, + menu, onOpen, onShare, onRename, @@ -800,27 +1162,145 @@ function ArtifactCard({ onInstall, onUninstall, onPreview, -}: CardActions & { - item: Artifact; - server: boolean; - canInstall: boolean; -}) { +}: ItemProps & { menu: ReturnType> }) { const { t } = useTranslation("artifacts"); - const menu = useContextMenu(); + if (!menu.menu) return null; + const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a); const pick = (fn: () => void) => () => { menu.close(); fn(); }; - const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a); - // The primary action already offers Preview/Download for a plain file. - const primaryIsPreview = !installable && a.kind !== "page" && canPreview; + return ( + + {t("details")} + {a.kind === "page" && a.url && ( + <> + window.open(a.url!, "_blank", "noopener,noreferrer"))}> + {t("open")} + + void copyText(a.url!))}>{t("copyLink")} + + )} + {canPreview && ( + onPreview(previewPath))}>{t("preview")} + )} + {canDownload && ( + { + window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true); + })} + > + {t("download")} + + )} + {installable && !a.installed && ( + + {t("install")} + + )} + {a.installed && ( + + {t("uninstall")} + + )} + {a.canChange && ( + <> + + {server && ( + + {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} + + )} + {t("rename")} + {!hubOnly && ( + + {t("delete")} + + )} + + )} + + ); +} + +function MoreButton({ onOpen }: { onOpen: (e: React.MouseEvent) => void }) { + const { t } = useTranslation("artifacts"); + return ( + + ); +} + +/** The list view's row: kind, name and one line of what it is, who and how + * many installed it, the one primary action, and ⋯ for everything else. */ +function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) { + const { item: a, server, canInstall, onOpen, onInstall, onPreview } = props; + const { t } = useTranslation("artifacts"); + const menu = useContextMenu(); return (
  • menu.open(e, null)} + > + + + + {/* A pack's row already says whose it is. */} + + + + menu.open(e, null)} /> + +
  • + ); +} + +/** The grid view's card: the same facts with room for two lines of description. */ +function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }) { + const { item: a, server, canInstall, onOpen, onInstall, onPreview } = props; + const { t } = useTranslation("artifacts"); + const menu = useContextMenu(); + const Tag = as; + // The source line only when it says something the title does not. + const sub = [ + t(`kindOne.${a.kind}`), + a.source && a.source !== a.title ? a.source : null, + fmtBytes(a.size), + ] + .filter(Boolean) + .join(" · "); + return ( + menu.open(e, null)} >
    +
    - {a.shared && ( - - {t(hubOnly ? "hubOnly" : "shared")} - - )} - {a.auto && ( - - {t("auto")} + {((server && !a.shared) || a.auto || capabilities(a).hubOnly) && ( + + )} {a.description && (

    {a.description}

    )} -
    - - {server && a.owner && !a.mine && ( - {t("by", { name: a.owner })} · - )} - - {fmtDate(a.createdAt)} - - {a.installs > 0 && ( - <> - · - - - )} - - - {canPreview && !primaryIsPreview && ( - - )} - {canDownload && primaryIsPreview && ( - - {t("download")} - - )} - {server && a.canChange && ( - - )} - {a.canChange && ( - - )} - +
    + + + menu.open(e, null)} />
    - {menu.menu && ( - - {t("details")} - {a.kind === "page" && a.url && ( - <> - window.open(a.url!, "_blank", "noopener,noreferrer"))}> - {t("open")} - - void copyText(a.url!))}>{t("copyLink")} - - )} - {canPreview && ( - onPreview(previewPath))}>{t("preview")} - )} - {canDownload && ( - { - window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true); - })} - > - {t("download")} - - )} - {installable && !a.installed && ( - - {t("install")} - - )} - {a.installed && ( - - {t("uninstall")} - - )} - {a.canChange && ( - <> - - {server && ( - - {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} - - )} - {t("rename")} - {!hubOnly && ( - - {t("delete")} - - )} - - )} - - )} - + + ); } diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index ab97e766..3856f02d 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -13,6 +13,9 @@ * the sandbox cannot show (Office, zip, PDF) offers Download only, the * Add menu closes on Escape or a click outside it, the front page is shelved * by kind until a category is picked, and a card opens its detail sheet. + * Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu. + * A section shows 30 entries before "Show more", and three or more skills of + * one owner sharing a name prefix are one pack row that installs them all. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -113,6 +116,16 @@ const addMenu = async (user: ReturnType, label: string) await user.click(screen.getByRole("button", { name: label })); }; +/** Run one item of the first (or the given) row's ⋯ menu. */ +async function menuAction( + user: ReturnType, + name: string, + scope: HTMLElement = document.body +) { + await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!); + await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name })); +} + describe("Adding", () => { it("adds the plugins picked from a marketplace link", async () => { const user = userEvent.setup(); @@ -218,7 +231,11 @@ describe("Artifacts", () => { const user = userEvent.setup(); open(); expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2); + await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!); + expect( + within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" }) + ).toBeInTheDocument(); + await user.keyboard("{Escape}"); // One merged list by default; Hub is a filter over it. expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" })); await user.click(screen.getByRole("button", { name: "Hub" })); @@ -271,20 +288,46 @@ describe("Artifacts", () => { expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument(); }); - it("pages a long section 12 cards at a time", async () => { + it("shows a long section 30 entries at a time, then the rest on Show more", async () => { vi.mocked(api.artifacts.list).mockResolvedValue({ - items: Array.from({ length: 14 }, (_, i) => + items: Array.from({ length: 34 }, (_, i) => item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] }) ), }); const user = userEvent.setup(); open(); await screen.findByText("p1"); - expect(screen.queryByText("p13")).toBeNull(); - expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Next" })); - expect(screen.getByText("p13")).toBeInTheDocument(); - expect(screen.queryByText("p1")).toBeNull(); + expect(screen.getByText("p30")).toBeInTheDocument(); + expect(screen.queryByText("p31")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Show 4 more" })); + expect(screen.getByText("p34")).toBeInTheDocument(); + expect(screen.getByText("p1")).toBeInTheDocument(); + }); + + it("folds three skills sharing a name prefix into one pack that installs them all", async () => { + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }), + item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }), + item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }), + item({ id: 4, kind: "skill", title: "lone", source: "lone" }), + ], + }); + vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never); + const user = userEvent.setup(); + open(); + const pack = await screen.findByTestId("pack-sg"); + expect(within(pack).getByText("sg-* pack")).toBeInTheDocument(); + expect(within(pack).getByText("3 skills")).toBeInTheDocument(); + expect(screen.queryByTestId("artifact-1")).toBeNull(); + expect(screen.getByTestId("artifact-4")).toBeInTheDocument(); + await user.click(within(pack).getByRole("button", { expanded: false })); + expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument(); + vi.mocked(confirmDialog).mockResolvedValueOnce(true); + await user.click(within(pack).getByRole("button", { name: "Install 2 more" })); + await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2)); + expect(api.artifacts.install).toHaveBeenCalledWith(1); + expect(api.artifacts.install).toHaveBeenCalledWith(3); }); it("lists who installed an artifact, when and where, from its install count", async () => { @@ -333,7 +376,7 @@ describe("Artifacts", () => { expect(within(card).getByText("Hub only")).toBeInTheDocument(); expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull(); vi.mocked(confirmDialog).mockResolvedValueOnce(true); - await user.click(within(card).getByRole("button", { name: "Remove from Hub" })); + await menuAction(user, "Remove from Hub", card); await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false })); const calls = vi.mocked(confirmDialog).mock.calls; expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); @@ -436,7 +479,7 @@ describe("Artifacts", () => { const user = userEvent.setup(); open(); await screen.findByText("standup"); - await user.click(screen.getByRole("button", { name: "Preview" })); + await menuAction(user, "Preview"); expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument(); expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html"); expect(screen.queryByTestId("artifact-preview")).toBeNull(); @@ -464,7 +507,7 @@ describe("Artifacts", () => { const user = userEvent.setup(); open(); await screen.findByText("standup"); - await user.click(screen.getByRole("button", { name: "Edit" })); + await menuAction(user, "Edit"); const name = await screen.findByLabelText(/Skill name/); expect(name).toHaveValue("standup"); expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");