Cleanup pass
This commit is contained in:
@@ -135,8 +135,14 @@ function CreateFlowLayoutContent({
|
||||
} = useCreateFlowNavigation(
|
||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||
);
|
||||
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState } =
|
||||
useCreateFlow();
|
||||
const {
|
||||
state,
|
||||
clearState,
|
||||
updateState,
|
||||
resetCustomRuleSelections,
|
||||
setMethodSectionsPinCommitted,
|
||||
replaceState,
|
||||
} = useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
const [communitySaveMagicLinkSubmitting, setCommunitySaveMagicLinkSubmitting] =
|
||||
|
||||
@@ -5,13 +5,14 @@ import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTempl
|
||||
import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug";
|
||||
import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields";
|
||||
import messages from "../../../../messages/en/index";
|
||||
import type { CreateFlowState } from "../types";
|
||||
import type {
|
||||
CreateFlowContextValue,
|
||||
CreateFlowState,
|
||||
} from "../types";
|
||||
|
||||
type AppRouterLike = { push: (_href: string) => void };
|
||||
type UpdateState = (_patch: Partial<CreateFlowState>) => void;
|
||||
type ReplaceStateFn = (
|
||||
_next: CreateFlowState | ((_prev: CreateFlowState) => CreateFlowState),
|
||||
) => void;
|
||||
type UpdateState = CreateFlowContextValue["updateState"];
|
||||
type ReplaceStateFn = CreateFlowContextValue["replaceState"];
|
||||
|
||||
export type UseTemplateReviewActionsResult = {
|
||||
/** True iff the current pathname is a template-review route (locale/basePath tolerant). */
|
||||
|
||||
@@ -123,7 +123,7 @@ export function FinalReviewScreen({
|
||||
[markCreateFlowInteraction, updateState, state],
|
||||
);
|
||||
|
||||
const { categories: finalReviewCategories, chipLookup } = useMemo(() => {
|
||||
const { categories: finalReviewCategories } = useMemo(() => {
|
||||
const { names, rows: fallbackRows } = readFallbackCategoryRows(
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
);
|
||||
@@ -196,7 +196,7 @@ export function FinalReviewScreen({
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return { categories: cats, chipLookup: lookup };
|
||||
return { categories: cats };
|
||||
}, [
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
state,
|
||||
@@ -204,7 +204,6 @@ export function FinalReviewScreen({
|
||||
goToStep,
|
||||
variant,
|
||||
]);
|
||||
void chipLookup;
|
||||
|
||||
const ruleCardTitle = useMemo(() => {
|
||||
const raw = typeof state.title === "string" ? state.title.trim() : "";
|
||||
|
||||
@@ -9,8 +9,9 @@ import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/valida
|
||||
*
|
||||
* No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
|
||||
* asc). With `facet.<group>=<value>` query params (repeatable per group),
|
||||
* templates are re-ranked by composed-method match count; ties fall back to
|
||||
* the curated order, score-0 templates remain at the end.
|
||||
* templates are re-ranked by `TemplateFacet` matrix match when seeded for
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -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`
|
||||
* + `entries[].title`) to the `CreateFlowState` keys the Create Custom Rule
|
||||
@@ -173,3 +143,19 @@ export function buildTemplateCustomizePrefill(
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
function normalizeChipState(s: unknown): ChipOption["state"] | undefined {
|
||||
return s === "selected" ||
|
||||
@@ -58,7 +41,10 @@ export function buildCoreValueChipOptionsFromDraft(
|
||||
const selected = new Set(selectedCoreValueIds ?? []);
|
||||
|
||||
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));
|
||||
|
||||
@@ -18,6 +18,39 @@ type TemplatesResponse = {
|
||||
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. */
|
||||
export type TemplateFacetScoreDto = {
|
||||
score: number;
|
||||
@@ -48,27 +81,23 @@ export function isTemplatesFetchAborted(e: unknown): boolean {
|
||||
export async function fetchTemplates(
|
||||
options?: FetchTemplatesOptions,
|
||||
): Promise<RuleTemplateDto[] | { error: string }> {
|
||||
try {
|
||||
const res = await fetch("/api/templates", {
|
||||
credentials: "include",
|
||||
signal: options?.signal,
|
||||
});
|
||||
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;
|
||||
const got = await getTemplatesJson("", options?.signal);
|
||||
if (got.ok === false) {
|
||||
if (got.error === "aborted") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
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) {
|
||||
return { error: "Could not load templates" };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/templates?${options.facetQuery}`, {
|
||||
credentials: "include",
|
||||
signal: options.signal,
|
||||
});
|
||||
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;
|
||||
const got = await getTemplatesJson(options.facetQuery, options.signal);
|
||||
if (got.ok === false) {
|
||||
if (got.error === "aborted") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -14,9 +14,8 @@ export type GovernanceTemplateCatalogEntry = {
|
||||
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
|
||||
iconPath: string;
|
||||
/**
|
||||
* When true, the templates grid shows the “RECOMMENDED” tag (facet-based
|
||||
* scores will set this in `ruleTemplateToGridEntry` when wired; catalog
|
||||
* entries omit unless intentionally static).
|
||||
* When true, static catalog rows show the “RECOMMENDED” tag. Facet-ranked
|
||||
* `/templates` sets this in `gridEntriesWithFacetScores` instead.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
@@ -114,22 +114,6 @@ export function gridEntriesWithFacetScores(
|
||||
const bySlug = (templates: RuleTemplateDto[]) =>
|
||||
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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user