Cleanup pass

This commit is contained in:
adilallo
2026-04-29 21:18:36 -06:00
parent 7fde82a94c
commit a31a36c926
9 changed files with 103 additions and 121 deletions
+8 -2
View File
@@ -135,8 +135,14 @@ function CreateFlowLayoutContent({
} = useCreateFlowNavigation( } = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined, skipCommunitySave ? { skipCommunitySave: true } : undefined,
); );
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState } = const {
useCreateFlow(); state,
clearState,
updateState,
resetCustomRuleSelections,
setMethodSectionsPinCommitted,
replaceState,
} = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } = const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner(); useCreateFlowDraftSaveBanner();
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] = const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
@@ -5,13 +5,14 @@ import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTempl
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug"; import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields"; import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
import messages from "../../../../messages/en/index"; import messages from "../../../../messages/en/index";
import type { CreateFlowState } from "../types"; import type {
CreateFlowContextValue,
CreateFlowState,
} from "../types";
type AppRouterLike = { push: (_href: string) => void }; type AppRouterLike = { push: (_href: string) => void };
type UpdateState = (_patch: Partial<CreateFlowState>) => void; type UpdateState = CreateFlowContextValue["updateState"];
type ReplaceStateFn = ( type ReplaceStateFn = CreateFlowContextValue["replaceState"];
_next: CreateFlowState | ((_prev: CreateFlowState) => CreateFlowState),
) => void;
export type UseTemplateReviewActionsResult = { export type UseTemplateReviewActionsResult = {
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */ /** True iff the current pathname is a template-review route (locale/basePath tolerant). */
@@ -123,7 +123,7 @@ export function FinalReviewScreen({
[markCreateFlowInteraction, updateState, state], [markCreateFlowInteraction, updateState, state],
); );
const { categories: finalReviewCategories, chipLookup } = useMemo(() => { const { categories: finalReviewCategories } = useMemo(() => {
const { names, rows: fallbackRows } = readFallbackCategoryRows( const { names, rows: fallbackRows } = readFallbackCategoryRows(
m.create.reviewAndComplete.finalReview.categories, m.create.reviewAndComplete.finalReview.categories,
); );
@@ -196,7 +196,7 @@ export function FinalReviewScreen({
: undefined, : undefined,
}; };
}); });
return { categories: cats, chipLookup: lookup }; return { categories: cats };
}, [ }, [
m.create.reviewAndComplete.finalReview.categories, m.create.reviewAndComplete.finalReview.categories,
state, state,
@@ -204,7 +204,6 @@ export function FinalReviewScreen({
goToStep, goToStep,
variant, variant,
]); ]);
void chipLookup;
const ruleCardTitle = useMemo(() => { const ruleCardTitle = useMemo(() => {
const raw = typeof state.title === "string" ? state.title.trim() : ""; const raw = typeof state.title === "string" ? state.title.trim() : "";
+3 -2
View File
@@ -9,8 +9,9 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida
* *
* No params → curated ordering (`featured` desc, `sortOrder` asc, `title` * No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
* asc). With `facet.<group>=<value>` query params (repeatable per group), * asc). With `facet.<group>=<value>` query params (repeatable per group),
* templates are re-ranked by composed-method match count; ties fall back to * templates are re-ranked by `TemplateFacet` matrix match when seeded for
* the curated order, score-0 templates remain at the end. * that slug, else by composed-method × `MethodFacet` match count; ties fall
* back to the curated order, score-0 templates remain at the end.
* *
* See `docs/guides/template-recommendation-matrix.md` §9.1. * See `docs/guides/template-recommendation-matrix.md` §9.1.
*/ */
+16 -30
View File
@@ -76,36 +76,6 @@ function buildCoreValuePrefill(
}; };
} }
/**
* Variant of {@link buildTemplateCustomizePrefill} that pulls *only* the
* Values section out of a template body. Used by the "Use without changes"
* handler so the verbatim template flow still seeds
* `coreValuesChipsSnapshot` + `selectedCoreValueIds` — without that, the
* final-review screen has no per-chip ids to attach edits to and falls
* back to the read-only chip modal for values.
*
* Returns an empty object when the body is malformed or has no Values
* section.
*/
export function buildCoreValuesPrefillFromTemplateBody(
body: unknown,
): Partial<CreateFlowState> {
if (!body || typeof body !== "object") return {};
const sections = (body as { sections?: unknown }).sections;
if (!Array.isArray(sections)) return {};
for (const raw of sections) {
if (!isTemplateSection(raw)) continue;
const key = normaliseCategoryKey(raw.categoryName as string);
if (key !== "values" && key !== "corevalues") continue;
const titles = entryTitles(raw.entries);
if (titles.length === 0) continue;
return buildCoreValuePrefill(titles);
}
return {};
}
/** /**
* Map a curated template `body` (DB shape — `sections[]` with `categoryName` * Map a curated template `body` (DB shape — `sections[]` with `categoryName`
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule * + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
@@ -173,3 +143,19 @@ export function buildTemplateCustomizePrefill(
return prefill; return prefill;
} }
/**
* Values section only — delegates to {@link buildTemplateCustomizePrefill}
* (same matching rules as Customize). Used by tests and any caller that
* needs core-value snapshot seeding without method fields.
*/
export function buildCoreValuesPrefillFromTemplateBody(
body: unknown,
): Partial<CreateFlowState> {
const full = buildTemplateCustomizePrefill(body);
if (full.selectedCoreValueIds === undefined) return {};
return {
selectedCoreValueIds: full.selectedCoreValueIds,
coreValuesChipsSnapshot: full.coreValuesChipsSnapshot,
};
}
+4 -18
View File
@@ -13,23 +13,6 @@ function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[]
})); }));
} }
function applySavedSelectionToPresetsOnly(
options: ChipOption[],
saved: string[] | undefined,
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("selected" as const)
: ("unselected" as const),
},
);
}
/** Valid MultiSelect chip state from snapshot JSON. */ /** Valid MultiSelect chip state from snapshot JSON. */
function normalizeChipState(s: unknown): ChipOption["state"] | undefined { function normalizeChipState(s: unknown): ChipOption["state"] | undefined {
return s === "selected" || return s === "selected" ||
@@ -58,7 +41,10 @@ export function buildCoreValueChipOptionsFromDraft(
const selected = new Set(selectedCoreValueIds ?? []); const selected = new Set(selectedCoreValueIds ?? []);
if (!snapshot?.length) { if (!snapshot?.length) {
return applySavedSelectionToPresetsOnly(presetBase, selectedCoreValueIds); return presetBase.map((opt) => ({
...opt,
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
}));
} }
const snapById = new Map(snapshot.map((r) => [r.id, r] as const)); const snapById = new Map(snapshot.map((r) => [r.id, r] as const));
+62 -42
View File
@@ -18,6 +18,39 @@ type TemplatesResponse = {
scores?: Record<string, TemplateFacetScoreDto>; scores?: Record<string, TemplateFacetScoreDto>;
}; };
function parseScoresPayload(
raw: unknown,
): Record<string, TemplateFacetScoreDto> {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return raw as Record<string, TemplateFacetScoreDto>;
}
return {};
}
async function getTemplatesJson(
queryString: string,
signal: AbortSignal | undefined,
): Promise<
| { ok: true; data: TemplatesResponse & { error?: string }; statusOk: boolean }
| { ok: false; error: "network" | "aborted" }
> {
const url =
queryString.length > 0 ? `/api/templates?${queryString}` : "/api/templates";
try {
const res = await fetch(url, {
credentials: "include",
signal,
});
const data = (await res.json()) as TemplatesResponse & { error?: string };
return { ok: true, data, statusOk: res.ok };
} catch (e) {
if (isAbortError(e)) {
return { ok: false, error: "aborted" };
}
return { ok: false, error: "network" };
}
}
/** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */ /** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */
export type TemplateFacetScoreDto = { export type TemplateFacetScoreDto = {
score: number; score: number;
@@ -48,27 +81,23 @@ export function isTemplatesFetchAborted(e: unknown): boolean {
export async function fetchTemplates( export async function fetchTemplates(
options?: FetchTemplatesOptions, options?: FetchTemplatesOptions,
): Promise<RuleTemplateDto[] | { error: string }> { ): Promise<RuleTemplateDto[] | { error: string }> {
try { const got = await getTemplatesJson("", options?.signal);
const res = await fetch("/api/templates", { if (got.ok === false) {
credentials: "include", if (got.error === "aborted") {
signal: options?.signal, throw new DOMException("Aborted", "AbortError");
});
const data = (await res.json()) as TemplatesResponse & { error?: string };
if (!res.ok) {
return {
error:
typeof data.error === "string"
? data.error
: "Could not load templates",
};
}
return Array.isArray(data.templates) ? data.templates : [];
} catch (e) {
if (isAbortError(e)) {
throw e;
} }
return { error: "Could not load templates" }; return { error: "Could not load templates" };
} }
const { data, statusOk } = got;
if (!statusOk) {
return {
error:
typeof data.error === "string"
? data.error
: "Could not load templates",
};
}
return Array.isArray(data.templates) ? data.templates : [];
} }
/** /**
@@ -82,33 +111,24 @@ export async function fetchRankedTemplatesByFacets(options: {
if (options.facetQuery.length === 0) { if (options.facetQuery.length === 0) {
return { error: "Could not load templates" }; return { error: "Could not load templates" };
} }
try { const got = await getTemplatesJson(options.facetQuery, options.signal);
const res = await fetch(`/api/templates?${options.facetQuery}`, { if (got.ok === false) {
credentials: "include", if (got.error === "aborted") {
signal: options.signal, throw new DOMException("Aborted", "AbortError");
});
const data = (await res.json()) as TemplatesResponse & { error?: string };
if (!res.ok) {
return {
error:
typeof data.error === "string"
? data.error
: "Could not load templates",
};
}
const templates = Array.isArray(data.templates) ? data.templates : [];
const raw = data.scores;
const scores: Record<string, TemplateFacetScoreDto> =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, TemplateFacetScoreDto>)
: {};
return { templates, scores };
} catch (e) {
if (isAbortError(e)) {
throw e;
} }
return { error: "Could not load templates" }; return { error: "Could not load templates" };
} }
const { data, statusOk } = got;
if (!statusOk) {
return {
error:
typeof data.error === "string"
? data.error
: "Could not load templates",
};
}
const templates = Array.isArray(data.templates) ? data.templates : [];
return { templates, scores: parseScoresPayload(data.scores) };
} }
export async function fetchTemplateBySlug( export async function fetchTemplateBySlug(
+2 -3
View File
@@ -14,9 +14,8 @@ export type GovernanceTemplateCatalogEntry = {
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */ /** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
iconPath: string; iconPath: string;
/** /**
* When true, the templates grid shows the “RECOMMENDED” tag (facet-based * When true, static catalog rows show the “RECOMMENDED” tag. Facet-ranked
* scores will set this in `ruleTemplateToGridEntry` when wired; catalog * `/templates` sets this in `gridEntriesWithFacetScores` instead.
* entries omit unless intentionally static).
*/ */
recommended?: boolean; recommended?: boolean;
}; };
-16
View File
@@ -114,22 +114,6 @@ export function gridEntriesWithFacetScores(
const bySlug = (templates: RuleTemplateDto[]) => const bySlug = (templates: RuleTemplateDto[]) =>
new Map(templates.map((t) => [t.slug, t] as const)); new Map(templates.map((t) => [t.slug, t] as const));
/**
* Ordered subset for home: follow `slugOrder`; skip missing slugs.
*/
export function gridEntriesForSlugOrder(
templates: RuleTemplateDto[],
slugOrder: readonly string[],
): TemplateGridCardEntry[] {
const map = bySlug(templates);
const out: TemplateGridCardEntry[] = [];
for (const slug of slugOrder) {
const t = map.get(slug);
if (t) out.push(ruleTemplateToGridEntry(t));
}
return out;
}
/** /**
* Home row: prefer API row per slug; if missing, use static Figma catalog entry. * Home row: prefer API row per slug; if missing, use static Figma catalog entry.
*/ */