Template remove add, read-only, chips open modals

This commit is contained in:
adilallo
2026-04-20 13:14:56 -06:00
parent 45bbbb8a35
commit d3bb8cdd0f
9 changed files with 632 additions and 43 deletions
@@ -31,6 +31,7 @@ const RuleCardContainer = memo<RuleCardProps>(
logoUrl,
logoAlt,
communityInitials,
hideCategoryAddButton = false,
}) => {
const size = sizeProp ?? "L";
@@ -76,6 +77,7 @@ const RuleCardContainer = memo<RuleCardProps>(
logoUrl={logoUrl}
logoAlt={logoAlt}
communityInitials={communityInitials}
hideCategoryAddButton={hideCategoryAddButton}
/>
);
},
@@ -26,6 +26,8 @@ export interface RuleCardProps {
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
hideCategoryAddButton?: boolean;
}
export interface RuleCardViewProps {
@@ -42,4 +44,5 @@ export interface RuleCardViewProps {
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
hideCategoryAddButton?: boolean;
}
@@ -19,6 +19,7 @@ export function RuleCardView({
logoUrl,
logoAlt,
communityInitials,
hideCategoryAddButton = false,
}: RuleCardViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -280,7 +281,7 @@ export function RuleCardView({
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
}}
addButton={true}
addButton={!hideCategoryAddButton}
addButtonText="" // Empty text for icon-only circular button
className="w-full"
/>
@@ -0,0 +1,354 @@
"use client";
import { useMemo } from "react";
import Create from "../../modals/Create";
import Chip from "../../controls/Chip";
import InputLabel from "../../utility/InputLabel";
import ContentLockup from "../../type/ContentLockup";
import ModalTextAreaField from "../../../(app)/create/components/ModalTextAreaField";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import type { TemplateChipDetail } from "../../../../lib/create/templateReviewMapping";
export interface TemplateChipDetailModalProps {
isOpen: boolean;
onClose: () => void;
detail: TemplateChipDetail | null;
}
/**
* Read-only mirror of the custom-rule per-chip modals. Shows the exact text
* from `messages/en/create/customRule/*.json` for the matched preset — never
* the template `body` placeholder. When no preset is found for the chip label,
* the modal surfaces a clear "details not available" note rather than falling
* back to seed copy.
*/
export function TemplateChipDetailModal({
isOpen,
onClose,
detail,
}: TemplateChipDetailModalProps) {
const m = useMessages();
const t = useTranslation("create.templateReview.chipDetailModal");
const resolved = useMemo(() => resolveChipContent(detail, m), [detail, m]);
return (
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="loginYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={resolved?.title ?? ""}
description={resolved?.subtitle ?? ""}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton
onNext={onClose}
nextButtonText={t("closeButton")}
ariaLabel={resolved?.title || "Template entry details"}
>
<div className="flex flex-col gap-[var(--measures-spacing-600,24px)] pb-2">
{resolved?.body ?? (
<p className="font-inter text-[14px] leading-[20px] text-[color:var(--color-content-default-secondary,#a3a3a3)]">
{t("fallback.bodyLabel")}
</p>
)}
</div>
</Create>
);
}
type ResolvedChipContent = {
title: string;
subtitle: string;
body: React.ReactNode;
};
function resolveChipContent(
detail: TemplateChipDetail | null,
m: ReturnType<typeof useMessages>,
): ResolvedChipContent | null {
if (!detail) return null;
const title = detail.chipLabel;
switch (detail.groupKey) {
case "coreValues": {
const cv = m.create.customRule.coreValues;
const preset = findCoreValuePreset(cv.values, detail.chipLabel);
if (!preset) return noPresetFallback(title);
return {
title,
subtitle: cv.detailModal.subtitle,
body: (
<>
<ModalTextAreaField
label={cv.detailModal.meaningLabel}
value={preset.meaning}
onChange={noop}
disabled
rows={4}
/>
<ModalTextAreaField
label={cv.detailModal.signalsLabel}
value={preset.signals}
onChange={noop}
disabled
rows={4}
/>
</>
),
};
}
case "communication": {
const comm = m.create.customRule.communication;
const preset = findMethodByLabel(comm.methods, detail.chipLabel);
if (!preset) return noPresetFallback(title);
return {
title,
subtitle: preset.supportText,
body: (
<>
<ModalTextAreaField
label={comm.sectionHeadings.corePrinciple}
value={preset.sections.corePrinciple}
onChange={noop}
disabled
rows={6}
/>
<ModalTextAreaField
label={comm.sectionHeadings.logisticsAdmin}
value={preset.sections.logisticsAdmin}
onChange={noop}
disabled
rows={6}
/>
<ModalTextAreaField
label={comm.sectionHeadings.codeOfConduct}
value={preset.sections.codeOfConduct}
onChange={noop}
disabled
rows={6}
/>
</>
),
};
}
case "membership": {
const mem = m.create.customRule.membership;
const preset = findMethodByLabel(mem.methods, detail.chipLabel);
if (!preset) return noPresetFallback(title);
return {
title,
subtitle: preset.supportText,
body: (
<>
<ModalTextAreaField
label={mem.sectionHeadings.eligibility}
value={preset.sections.eligibility}
onChange={noop}
disabled
rows={6}
/>
<ModalTextAreaField
label={mem.sectionHeadings.joiningProcess}
value={preset.sections.joiningProcess}
onChange={noop}
disabled
rows={6}
/>
<ModalTextAreaField
label={mem.sectionHeadings.expectations}
value={preset.sections.expectations}
onChange={noop}
disabled
rows={6}
/>
</>
),
};
}
case "decisionApproaches": {
const da = m.create.customRule.decisionApproaches;
const preset = findMethodByLabel(da.methods, detail.chipLabel);
if (!preset) return noPresetFallback(title);
return {
title,
subtitle: preset.supportText,
body: (
<>
<ModalTextAreaField
label={da.sectionHeadings.corePrinciple}
value={preset.sections.corePrinciple}
onChange={noop}
disabled
rows={4}
/>
<ReadOnlyScopeField
label={da.sectionHeadings.applicableScope}
scopes={preset.sections.applicableScope}
/>
<ModalTextAreaField
label={da.sectionHeadings.stepByStepInstructions}
value={preset.sections.stepByStepInstructions}
onChange={noop}
disabled
rows={4}
/>
<ReadOnlyValueField
label={da.sectionHeadings.consensusLevel}
value={`${preset.sections.consensusLevel}%`}
/>
<ModalTextAreaField
label={da.sectionHeadings.objectionsDeadlocks}
value={preset.sections.objectionsDeadlocks}
onChange={noop}
disabled
rows={4}
/>
</>
),
};
}
case "conflictManagement": {
const cm = m.create.customRule.conflictManagement;
const preset = findMethodByLabel(cm.methods, detail.chipLabel);
if (!preset) return noPresetFallback(title);
return {
title,
subtitle: preset.supportText,
body: (
<>
<ModalTextAreaField
label={cm.sectionHeadings.corePrinciple}
value={preset.sections.corePrinciple}
onChange={noop}
disabled
rows={4}
/>
<ReadOnlyScopeField
label={cm.sectionHeadings.applicableScope}
scopes={preset.sections.applicableScope}
/>
<ModalTextAreaField
label={cm.sectionHeadings.processProtocol}
value={preset.sections.processProtocol}
onChange={noop}
disabled
rows={4}
/>
<ModalTextAreaField
label={cm.sectionHeadings.restorationFallbacks}
value={preset.sections.restorationFallbacks}
onChange={noop}
disabled
rows={4}
/>
</>
),
};
}
default:
return noPresetFallback(title);
}
}
function noPresetFallback(title: string): ResolvedChipContent {
return { title, subtitle: "", body: null };
}
function noop() {
/* read-only */
}
/**
* Minimal read-only Applicable Scope row — locked chips shown as "selected"
* without the "+ Add" affordance.
*/
function ReadOnlyScopeField({
label,
scopes,
}: {
label: string;
scopes: readonly string[];
}) {
return (
<div className="flex flex-col gap-2">
<InputLabel label={label} helpIcon size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => (
<Chip
key={scope}
label={scope}
state="selected"
palette="default"
size="s"
disabled
/>
))}
</div>
</div>
);
}
function ReadOnlyValueField({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="flex flex-col gap-2">
<InputLabel label={label} helpIcon size="s" palette="default" />
<span className="font-inter text-[16px] font-medium leading-[20px] text-[color:var(--color-content-default-primary)]">
{value}
</span>
</div>
);
}
/** Case-insensitive, trim-tolerant method lookup by `label`. */
function findMethodByLabel<T extends { label: string }>(
methods: readonly T[],
label: string,
): T | undefined {
const normalized = label.trim().toLowerCase();
return methods.find((m) => m.label.trim().toLowerCase() === normalized);
}
type CoreValuePreset = { label: string; meaning: string; signals: string };
function findCoreValuePreset(
values: readonly unknown[],
label: string,
): CoreValuePreset | undefined {
const normalized = label.trim().toLowerCase();
for (const v of values) {
if (
v &&
typeof v === "object" &&
"label" in v &&
typeof (v as CoreValuePreset).label === "string" &&
(v as CoreValuePreset).label.trim().toLowerCase() === normalized
) {
const preset = v as Partial<CoreValuePreset>;
return {
label: preset.label ?? label,
meaning: preset.meaning ?? "",
signals: preset.signals ?? "",
};
}
}
return undefined;
}
@@ -1,18 +1,23 @@
"use client";
import { useMemo, useState } from "react";
import Image from "next/image";
import RuleCard from "../RuleCard";
import type { RuleCardProps } from "../RuleCard/RuleCard.types";
import type {
Category,
RuleCardProps,
} from "../RuleCard/RuleCard.types";
import { getAssetPath } from "../../../../lib/assetUtils";
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
import {
templateBodyToCategories,
templateBodyToReviewData,
templateSummaryFromBody,
} from "../../../../lib/create/templateReviewMapping";
import {
getGovernanceTemplateCatalogEntry,
} from "../../../../lib/templates/governanceTemplateCatalog";
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
import { TemplateChipDetailModal } from "./TemplateChipDetailModal";
export interface TemplateReviewCardProps {
template: RuleTemplateDto;
@@ -24,7 +29,9 @@ export interface TemplateReviewCardProps {
/**
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
* tag rows from API `body`.
* tag rows from API `body`. Chip clicks open a read-only detail modal per
* facet group (values / communication / membership / decision-making / conflict
* management) so reviewers can see what each chip means without editing.
*/
export function TemplateReviewCard({
template,
@@ -33,33 +40,60 @@ export function TemplateReviewCard({
}: TemplateReviewCardProps) {
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
const categories = templateBodyToCategories(template.body);
const { categories: rawCategories, chipDetailsByChipId } = useMemo(
() => templateBodyToReviewData(template.body),
[template.body],
);
const summary = templateSummaryFromBody(template.description, template.body);
const [activeChipId, setActiveChipId] = useState<string | null>(null);
const categories = useMemo<Category[]>(
() =>
rawCategories.map((category) => ({
...category,
onChipClick: (_categoryName, chipId) => {
setActiveChipId(chipId);
},
})),
[rawCategories],
);
const activeDetail =
activeChipId !== null ? chipDetailsByChipId[activeChipId] ?? null : null;
return (
<RuleCard
title={template.title}
description={summary}
expanded
size={size}
categories={categories}
backgroundColor={pres.backgroundColor}
className={ruleCardClassName}
onClick={() => {}}
icon={
<Image
src={getAssetPath(pres.iconPath)}
alt={template.title}
width={90}
height={90}
className="
max-[639px]:w-[40px] max-[639px]:h-[40px]
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
"
/>
}
/>
<>
<RuleCard
title={template.title}
description={summary}
expanded
size={size}
categories={categories}
backgroundColor={pres.backgroundColor}
className={ruleCardClassName}
onClick={() => {}}
hideCategoryAddButton
icon={
<Image
src={getAssetPath(pres.iconPath)}
alt={template.title}
width={90}
height={90}
className="
max-[639px]:w-[40px] max-[639px]:h-[40px]
min-[640px]:max-[1023px]:w-[56px] min-[640px]:max-[1023px]:h-[56px]
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[90px] min-[1440px]:h-[90px]
"
/>
}
/>
<TemplateChipDetailModal
isOpen={activeChipId !== null}
onClose={() => setActiveChipId(null)}
detail={activeDetail}
/>
</>
);
}