b8cce4f814
F1 [WARNING] bundlePhase returned 'allInstalled' when a bundle's only non-installed role was skipped (0 installed for it), so the collapsed green 'All installed · up to date' header contradicted the open 'Installed 0 · 1 skipped' plaque. It now returns 'mixed' whenever a skipped role is present. Fixed the test that encoded the wrong behavior. F2 [WARNING] The reason->action branch (name-conflict -> transient overlay + 'Rename & install'; already-installed -> informational, no button) lived only in the component, untested. Extracted the two decisions into pure, unit-tested helpers nameConflictSlugs() and partialOffersRename() and wired them into the modal; both reason values are now covered. F3 [low] Removed the unused useRef import (client eslint no-unused-vars is off, so it shipped silently). F4 [low] Extracted bundleCounts() as the single tally pass; bundlePhase and the panel both derive from it instead of rescanning the roles array ~5x per render (the same model<->component consolidation this PR is about). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
7.2 KiB
TypeScript
207 lines
7.2 KiB
TypeScript
import type {
|
|
IAiRole,
|
|
IAiRoleCatalogRole,
|
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
|
|
|
/**
|
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
|
* (readable without expanding) and a single primary action. The per-role and
|
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
|
* computation are unit-testable without mounting the component (mirrors the
|
|
* `catalogRoleInstallState` precedent).
|
|
*/
|
|
|
|
/**
|
|
* A role's status in the catalog view model.
|
|
* - `import` — not installed in the current content language.
|
|
* - `installed` — installed and up to date.
|
|
* - `update` — installed, but the catalog ships a newer version.
|
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
|
* (a name collision under `conflict:'skip'`); never from the
|
|
* backend.
|
|
*/
|
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
|
|
|
/** A catalog role mapped into the modal's view model. */
|
|
export interface CatalogViewRole {
|
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
|
// `slugs[]` payload for import.
|
|
slug: string;
|
|
// Optional in the catalog — the row reserves space and renders nothing when
|
|
// absent.
|
|
emoji?: string;
|
|
name: string;
|
|
description: string;
|
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
|
version: number;
|
|
newVersion?: number;
|
|
status: RoleStatus;
|
|
// The language a same-slug role is installed under, when it differs from the
|
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
|
installedLang?: string;
|
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
|
// update-from-catalog mutation.
|
|
installedRoleId?: string;
|
|
}
|
|
|
|
/**
|
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
|
* the collapsed-header summary and the bundle's single primary action.
|
|
* - `empty` — the bundle has no roles.
|
|
* - `allNew` — everything is importable, nothing installed.
|
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
|
* - `updates` — updates available and nothing left to import.
|
|
* - `mixed` — any other combination.
|
|
*/
|
|
export type BundlePhase =
|
|
| "empty"
|
|
| "allNew"
|
|
| "allInstalled"
|
|
| "updates"
|
|
| "mixed";
|
|
|
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
|
export interface BundleCounts {
|
|
importable: number;
|
|
installed: number;
|
|
update: number;
|
|
skipped: number;
|
|
}
|
|
|
|
/**
|
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
|
*/
|
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
|
const counts: BundleCounts = {
|
|
importable: 0,
|
|
installed: 0,
|
|
update: 0,
|
|
skipped: 0,
|
|
};
|
|
for (const r of roles) {
|
|
if (r.status === "import") counts.importable += 1;
|
|
else if (r.status === "installed") counts.installed += 1;
|
|
else if (r.status === "update") counts.update += 1;
|
|
else if (r.status === "skipped") counts.skipped += 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
|
if (roles.length === 0) return "empty";
|
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
|
// would make the collapsed green "up to date" header contradict the open
|
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
|
return "allNew";
|
|
return "mixed";
|
|
}
|
|
|
|
/**
|
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
|
*/
|
|
export function nameConflictSlugs(
|
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
|
): string[] {
|
|
return skipped
|
|
.filter((s) => s.reason === "name-conflict")
|
|
.map((s) => s.slug);
|
|
}
|
|
|
|
/**
|
|
* Whether a partial-import result should offer the "Rename & install" action:
|
|
* only when at least one skip is a name conflict (renameable). An
|
|
* `already-installed`-only partial is informational.
|
|
*/
|
|
export function partialOffersRename(
|
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
|
): boolean {
|
|
return skipped.some((s) => s.reason === "name-conflict");
|
|
}
|
|
|
|
/**
|
|
* For a role NOT installed in the current `language`, find a workspace role with
|
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
|
* language. Drives the "installed in another language" hint (Р5): a different
|
|
* language of the same slug is a separate install and appears as `import`.
|
|
*/
|
|
export function installedLangForRole(
|
|
slug: string,
|
|
workspaceRoles: IAiRole[],
|
|
language: string,
|
|
): string | undefined {
|
|
const other = workspaceRoles.find(
|
|
(r) =>
|
|
r.source?.slug === slug &&
|
|
!!r.source?.language &&
|
|
r.source.language !== language,
|
|
);
|
|
return other?.source?.language;
|
|
}
|
|
|
|
/**
|
|
* Map one catalog role to the view model, computing its install status against
|
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
|
* the other-language hint.
|
|
*/
|
|
export function mapCatalogRoleToView(
|
|
role: IAiRoleCatalogRole,
|
|
workspaceRoles: IAiRole[],
|
|
language: string,
|
|
): CatalogViewRole {
|
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
|
const base = {
|
|
slug: role.slug,
|
|
emoji: role.emoji ?? undefined,
|
|
name: role.name,
|
|
description: role.description ?? "",
|
|
};
|
|
if (state.state === "update") {
|
|
return {
|
|
...base,
|
|
status: "update",
|
|
version: state.fromVersion,
|
|
newVersion: state.toVersion,
|
|
installedRoleId: state.installed.id,
|
|
};
|
|
}
|
|
if (state.state === "installed") {
|
|
return {
|
|
...base,
|
|
status: "installed",
|
|
version: role.version,
|
|
installedRoleId: state.installed.id,
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
status: "import",
|
|
version: role.version,
|
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
|
*/
|
|
export function mapBundleRolesToView(
|
|
roles: IAiRoleCatalogRole[],
|
|
workspaceRoles: IAiRole[],
|
|
language: string,
|
|
): CatalogViewRole[] {
|
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
|
}
|