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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,27 +21,111 @@ function isDocumentSection(
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API template `body` (published-rule document shape) to RuleCard category rows.
|
||||
* Known facet groups that template sections map to. Matches the five modals on
|
||||
* the custom-rule create flow (`m.create.customRule.*`).
|
||||
*/
|
||||
export function templateBodyToCategories(body: unknown): Category[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const sections = (body as Record<string, unknown>).sections;
|
||||
if (!Array.isArray(sections)) return [];
|
||||
export type TemplateFacetGroupKey =
|
||||
| "coreValues"
|
||||
| "communication"
|
||||
| "membership"
|
||||
| "decisionApproaches"
|
||||
| "conflictManagement";
|
||||
|
||||
const out: Category[] = [];
|
||||
/**
|
||||
* Normalize a section `categoryName` (as it appears in a template's `body`)
|
||||
* to the custom-rule facet-group key. Returns `null` for unknown categories.
|
||||
* Keys are matched case- and punctuation-insensitively so variations like
|
||||
* "Decision making" / "Decision-making" resolve to the same group.
|
||||
*/
|
||||
export function templateCategoryToGroupKey(
|
||||
categoryName: string,
|
||||
): TemplateFacetGroupKey | null {
|
||||
const key = categoryName.toLowerCase().replace(/[^a-z]+/g, "");
|
||||
switch (key) {
|
||||
case "values":
|
||||
case "corevalues":
|
||||
return "coreValues";
|
||||
case "communication":
|
||||
case "communications":
|
||||
return "communication";
|
||||
case "membership":
|
||||
case "memberships":
|
||||
return "membership";
|
||||
case "decisionmaking":
|
||||
case "decisionapproaches":
|
||||
case "decisions":
|
||||
return "decisionApproaches";
|
||||
case "conflictmanagement":
|
||||
case "conflict":
|
||||
case "conflictresolution":
|
||||
return "conflictManagement";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail for a single chip rendered on a template review — includes the raw
|
||||
* entry fields plus the facet-group key so a click can open the matching
|
||||
* read-only modal (chip `label` is used to look up the preset method inside
|
||||
* the group).
|
||||
*/
|
||||
export interface TemplateChipDetail {
|
||||
chipId: string;
|
||||
chipLabel: string;
|
||||
categoryName: string;
|
||||
groupKey: TemplateFacetGroupKey | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API template `body` (published-rule document shape) to RuleCard category
|
||||
* rows **plus** a chipId → detail lookup for wiring chip clicks to the
|
||||
* read-only detail modal.
|
||||
*/
|
||||
export function templateBodyToReviewData(body: unknown): {
|
||||
categories: Category[];
|
||||
chipDetailsByChipId: Record<string, TemplateChipDetail>;
|
||||
} {
|
||||
const empty = { categories: [] as Category[], chipDetailsByChipId: {} };
|
||||
if (!body || typeof body !== "object") return empty;
|
||||
const sections = (body as Record<string, unknown>).sections;
|
||||
if (!Array.isArray(sections)) return empty;
|
||||
|
||||
const categories: Category[] = [];
|
||||
const chipDetailsByChipId: Record<string, TemplateChipDetail> = {};
|
||||
for (const raw of sections) {
|
||||
if (!isDocumentSection(raw)) continue;
|
||||
const chipOptions: ChipOption[] = raw.entries.map((e, i) => ({
|
||||
id: `${raw.categoryName}-${i}`,
|
||||
label: e.title,
|
||||
state: "unselected",
|
||||
}));
|
||||
out.push({
|
||||
const groupKey = templateCategoryToGroupKey(raw.categoryName);
|
||||
const chipOptions: ChipOption[] = raw.entries.map((e, i) => {
|
||||
const chipId = `${raw.categoryName}-${i}`;
|
||||
chipDetailsByChipId[chipId] = {
|
||||
chipId,
|
||||
chipLabel: e.title,
|
||||
categoryName: raw.categoryName,
|
||||
groupKey,
|
||||
body: e.body,
|
||||
};
|
||||
return {
|
||||
id: chipId,
|
||||
label: e.title,
|
||||
state: "unselected",
|
||||
};
|
||||
});
|
||||
categories.push({
|
||||
name: raw.categoryName,
|
||||
chipOptions,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
return { categories, chipDetailsByChipId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible wrapper kept so existing consumers can still grab just
|
||||
* the rows when they don't need chip-click wiring.
|
||||
*/
|
||||
export function templateBodyToCategories(body: unknown): Category[] {
|
||||
return templateBodyToReviewData(body).categories;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -324,6 +324,41 @@
|
||||
"label": "Participation",
|
||||
"meaning": "Democracy requires the active engagement of all members in the decision-making process. To us, this means we require active contribution to maintain voting rights. We acknowledge a tension with Efficiency (Too many cooks).",
|
||||
"signals": "Creating barriers to entry that make it difficult for new members to have a voice or ignoring the input of the broader membership."
|
||||
},
|
||||
{
|
||||
"label": "Reliability",
|
||||
"meaning": "Members can count on one another to follow through on what they commit to. To us, this means we do what we say we will do and notify others early when plans must change. We acknowledge a tension with Flexibility (Sticking to the plan vs adapting).",
|
||||
"signals": "Missing deadlines without notice or making ambitious commitments the group knows it cannot keep."
|
||||
},
|
||||
{
|
||||
"label": "Resilience",
|
||||
"meaning": "The group can absorb shocks and keep functioning when conditions get hard. To us, this means we build redundancy into critical roles and document institutional knowledge so no single departure sinks the work. We acknowledge a tension with Efficiency (Backup capacity vs lean operations).",
|
||||
"signals": "Concentrating all critical knowledge in one person or ignoring warning signs of burnout until key members quit."
|
||||
},
|
||||
{
|
||||
"label": "Solidarity",
|
||||
"meaning": "We stand with one another, especially when members face pressure from outside forces. To us, this means we defend members against unjust treatment and share risk collectively rather than leaving anyone exposed. We acknowledge a tension with Individualism (Collective defense vs personal autonomy).",
|
||||
"signals": "Staying silent when a member faces mistreatment or leaving vulnerable members to handle external pressure alone."
|
||||
},
|
||||
{
|
||||
"label": "Stewardship",
|
||||
"meaning": "We take responsibility for tending the shared resources and relationships entrusted to us. To us, this means we make decisions with future members in mind and reinvest in the infrastructure we depend on. We acknowledge a tension with Efficiency (Long-term care vs short-term output).",
|
||||
"signals": "Extracting short-term value from shared resources without replenishing them or deferring maintenance until systems fail."
|
||||
},
|
||||
{
|
||||
"label": "Transparency",
|
||||
"meaning": "Decisions, finances, and processes are visible to the membership by default. To us, this means we publish meeting notes, budgets, and decision logs where all members can read them. We acknowledge a tension with Privacy (Open books vs personal disclosure).",
|
||||
"signals": "Making major decisions in private channels and only announcing outcomes, or hiding finances behind vague summaries."
|
||||
},
|
||||
{
|
||||
"label": "Trust",
|
||||
"meaning": "We extend good faith to one another and build relationships that can withstand disagreement. To us, this means we interpret others charitably and repair quickly when trust is strained. We acknowledge a tension with Accountability (Assuming good intent vs naming harm).",
|
||||
"signals": "Jumping to worst-case interpretations of ambiguous behavior or withholding repair when a breach could be addressed directly."
|
||||
},
|
||||
{
|
||||
"label": "Voluntarism",
|
||||
"meaning": "Participation is freely chosen rather than coerced, and contribution is recognized on its own terms. To us, this means we don't pressure members into tasks they haven't consented to and we honor their right to step back. We acknowledge a tension with Reliability (Free participation vs guaranteed coverage).",
|
||||
"signals": "Using guilt or social pressure to extract labor from members or shaming people who decline additional responsibilities."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@
|
||||
"notFound": "This template was not found.",
|
||||
"applyFailed": "Something went wrong. Please try again."
|
||||
},
|
||||
"loading": "Loading template…"
|
||||
"loading": "Loading template…",
|
||||
"chipDetailModal": {
|
||||
"closeButton": "Close",
|
||||
"fallback": {
|
||||
"bodyLabel": "Details for this entry are not yet available."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
templateBodyToCategories,
|
||||
templateBodyToReviewData,
|
||||
templateCategoryToGroupKey,
|
||||
templateSummaryFromBody,
|
||||
} from "../../lib/create/templateReviewMapping";
|
||||
|
||||
@@ -46,4 +48,72 @@ describe("templateReviewMapping", () => {
|
||||
};
|
||||
expect(templateSummaryFromBody("", body)).toBe("First paragraph.");
|
||||
});
|
||||
|
||||
describe("templateCategoryToGroupKey", () => {
|
||||
it.each([
|
||||
["Values", "coreValues"],
|
||||
["Core values", "coreValues"],
|
||||
["Communication", "communication"],
|
||||
["Membership", "membership"],
|
||||
["Decision-making", "decisionApproaches"],
|
||||
["Decision making", "decisionApproaches"],
|
||||
["Conflict management", "conflictManagement"],
|
||||
["Conflict Resolution", "conflictManagement"],
|
||||
] as const)("maps %s -> %s", (input, expected) => {
|
||||
expect(templateCategoryToGroupKey(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null for unknown categories", () => {
|
||||
expect(templateCategoryToGroupKey("Mystery")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateBodyToReviewData", () => {
|
||||
it("returns group-keyed chip detail lookup aligned with categories", () => {
|
||||
const body = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Decision-making",
|
||||
entries: [
|
||||
{ title: "Consensus Decision-Making", body: "body-1" },
|
||||
{ title: "Modified Consensus", body: "body-2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [{ title: "Solidarity", body: "body-3" }],
|
||||
},
|
||||
{
|
||||
categoryName: "Mystery",
|
||||
entries: [{ title: "Unknown", body: "body-4" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { categories, chipDetailsByChipId } = templateBodyToReviewData(body);
|
||||
expect(categories).toHaveLength(3);
|
||||
|
||||
const firstChipId = categories[0].chipOptions[0].id;
|
||||
expect(chipDetailsByChipId[firstChipId]).toEqual({
|
||||
chipId: firstChipId,
|
||||
chipLabel: "Consensus Decision-Making",
|
||||
categoryName: "Decision-making",
|
||||
groupKey: "decisionApproaches",
|
||||
body: "body-1",
|
||||
});
|
||||
expect(
|
||||
chipDetailsByChipId[categories[1].chipOptions[0].id].groupKey,
|
||||
).toBe("coreValues");
|
||||
expect(
|
||||
chipDetailsByChipId[categories[2].chipOptions[0].id].groupKey,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is resilient to bad body input", () => {
|
||||
expect(templateBodyToReviewData(null).categories).toEqual([]);
|
||||
expect(templateBodyToReviewData({}).chipDetailsByChipId).toEqual({});
|
||||
expect(templateBodyToReviewData({ sections: "nope" }).categories).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user