Template remove add, read-only, chips open modals
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user