Implement modals across create flow
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
export interface InlineTextButtonProps {
|
||||
/**
|
||||
* Button label content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Click handler.
|
||||
*/
|
||||
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
* Extra class names. Use `className` to override typography/color when the
|
||||
* button must inherit parent font-size/leading (e.g. mid-paragraph usage).
|
||||
*/
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
|
||||
/**
|
||||
* Small text-styled button for in-paragraph "link"-like controls (expand,
|
||||
* add, etc.). The Figma "link" treatment is a tertiary-colored underline with
|
||||
* a 3px underline-offset and inherited typography, which sits between a real
|
||||
* anchor and a styled `Button`.
|
||||
*
|
||||
* Use this anywhere a `<button>` is needed inline with body copy — do not use
|
||||
* for primary/secondary actions (reach for `Button` instead).
|
||||
*/
|
||||
function InlineTextButtonComponent({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
}: InlineTextButtonProps) {
|
||||
const baseClasses =
|
||||
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className={`${baseClasses} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
InlineTextButtonComponent.displayName = "InlineTextButton";
|
||||
|
||||
export default memo(InlineTextButtonComponent);
|
||||
@@ -33,6 +33,13 @@ export interface ChipProps {
|
||||
*/
|
||||
size?: ChipSizeValue;
|
||||
className?: string;
|
||||
/**
|
||||
* Whether the chip should be non-interactive. Defaults to `true` when
|
||||
* `state === "disabled"` to preserve historical behavior. Pass
|
||||
* `disabled={false}` alongside `state="Disabled"` to render the dimmed
|
||||
* "disabled" visual while keeping the chip clickable — useful for toggle
|
||||
* groups where the unselected state is the disabled Figma visual.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,10 @@ function ChipView({
|
||||
inputRef,
|
||||
ariaLabel,
|
||||
}: ChipViewProps) {
|
||||
const isDisabled = disabled || state === "disabled";
|
||||
// The container is the source of truth for `disabled`. This allows
|
||||
// `state="disabled"` to be used purely as a visual (for toggle-group chips
|
||||
// that look dimmed while remaining clickable) by passing `disabled={false}`.
|
||||
const isDisabled = disabled ?? false;
|
||||
const isSelected = state === "selected";
|
||||
const isCustom = state === "custom";
|
||||
|
||||
@@ -57,11 +60,13 @@ function ChipView({
|
||||
} else if (state === "disabled") {
|
||||
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
|
||||
border = "border-none";
|
||||
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
|
||||
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
|
||||
// strongly dimmed look, not default-tertiary.
|
||||
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
|
||||
} else if (isSelected) {
|
||||
background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
|
||||
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
|
||||
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
|
||||
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
|
||||
textColor = "text-[color:var(--color-content-invert-primary,black)]";
|
||||
} else {
|
||||
// Unselected default
|
||||
background =
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
export interface IncrementerProps {
|
||||
value: number;
|
||||
/** Minimum value (default `-Infinity`). */
|
||||
min?: number;
|
||||
/** Maximum value (default `Infinity`). */
|
||||
max?: number;
|
||||
/** Step size applied to +/- actions (default `1`). */
|
||||
step?: number;
|
||||
onChange: (_next: number) => void;
|
||||
/**
|
||||
* Optional formatter for the displayed value. Receives the raw number and
|
||||
* should return the rendered content. Default: `String(value)`.
|
||||
*/
|
||||
formatValue?: (_value: number) => React.ReactNode;
|
||||
/**
|
||||
* When true, the whole incrementer is non-interactive and the value renders
|
||||
* in the "inactive" (tertiary) color per Figma.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Accessible label for the decrement button (default "Decrease"). */
|
||||
decrementAriaLabel?: string;
|
||||
/** Accessible label for the increment button (default "Increase"). */
|
||||
incrementAriaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP_BUTTON_CLASSES =
|
||||
"bg-[var(--color-surface-default-secondary,#141414)] text-[var(--color-content-default-primary,#fff)] inline-flex shrink-0 items-center justify-center overflow-clip rounded-[var(--measures-radius-full,9999px)] px-[var(--space-200,8px)] py-[var(--measures-spacing-150,6px)] transition-[background,color,transform] duration-200 ease-in-out hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary,#fff)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary,#000)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100";
|
||||
|
||||
function MinusIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[12px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[12px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]`
|
||||
* row used for numeric step inputs (e.g. a percentage setting).
|
||||
*
|
||||
* For a labelled variant that matches "Control / Incrementer Block"
|
||||
* (`19883:13283`), compose with {@link IncrementerBlock} instead.
|
||||
*/
|
||||
function IncrementerComponent({
|
||||
value,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
step = 1,
|
||||
onChange,
|
||||
formatValue,
|
||||
disabled = false,
|
||||
decrementAriaLabel = "Decrease",
|
||||
incrementAriaLabel = "Increase",
|
||||
className = "",
|
||||
}: IncrementerProps) {
|
||||
const clampedValue = Math.min(Math.max(value, min), max);
|
||||
const atMin = clampedValue <= min;
|
||||
const atMax = clampedValue >= max;
|
||||
|
||||
const decrement = () => {
|
||||
if (disabled || atMin) return;
|
||||
onChange(Math.max(min, clampedValue - step));
|
||||
};
|
||||
const increment = () => {
|
||||
if (disabled || atMax) return;
|
||||
onChange(Math.min(max, clampedValue + step));
|
||||
};
|
||||
|
||||
const valueColor = disabled
|
||||
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
|
||||
: "text-[color:var(--color-content-default-primary,#fff)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-[16px] ${className}`.trim()}
|
||||
data-figma-node="17857:30943"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={disabled || atMin}
|
||||
aria-label={decrementAriaLabel}
|
||||
className={STEP_BUTTON_CLASSES}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
|
||||
>
|
||||
{formatValue ? formatValue(clampedValue) : clampedValue}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
disabled={disabled || atMax}
|
||||
aria-label={incrementAriaLabel}
|
||||
className={STEP_BUTTON_CLASSES}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
IncrementerComponent.displayName = "Incrementer";
|
||||
|
||||
export default memo(IncrementerComponent);
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Incrementer, { type IncrementerProps } from "./Incrementer";
|
||||
import InputLabel from "../../utility/InputLabel";
|
||||
import type {
|
||||
InputLabelPaletteValue,
|
||||
InputLabelSizeValue,
|
||||
} from "../../utility/InputLabel/InputLabel.types";
|
||||
|
||||
export interface IncrementerBlockProps extends IncrementerProps {
|
||||
/** Label text displayed above the incrementer. */
|
||||
label: string;
|
||||
/** Show the help "?" icon next to the label. Defaults to `true`. */
|
||||
helpIcon?: boolean;
|
||||
/**
|
||||
* Helper text shown to the right of the label. Pass a string or `true` to
|
||||
* render the default "Optional text".
|
||||
*/
|
||||
helperText?: boolean | string;
|
||||
/** Show an asterisk indicating a required field. */
|
||||
asterisk?: boolean;
|
||||
/**
|
||||
* Size of the label (`"s"` or `"m"`). Defaults to `"s"` to match the Figma
|
||||
* "Incrementer Block" spec.
|
||||
*/
|
||||
labelSize?: InputLabelSizeValue;
|
||||
/** Palette. Defaults to `"default"`. */
|
||||
palette?: InputLabelPaletteValue;
|
||||
/**
|
||||
* Class applied to the root `<div>` wrapping the label + incrementer. Use
|
||||
* this to control the block's layout width (e.g. `w-full`).
|
||||
*/
|
||||
blockClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus
|
||||
* an {@link Incrementer} row, stacked with a 12px gap.
|
||||
*/
|
||||
function IncrementerBlockComponent({
|
||||
label,
|
||||
helpIcon = true,
|
||||
helperText,
|
||||
asterisk,
|
||||
labelSize = "s",
|
||||
palette = "default",
|
||||
blockClassName = "",
|
||||
className,
|
||||
...incrementerProps
|
||||
}: IncrementerBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] py-[8px] ${blockClassName}`.trim()}
|
||||
data-figma-node="19883:13283"
|
||||
>
|
||||
<InputLabel
|
||||
label={label}
|
||||
helpIcon={helpIcon}
|
||||
helperText={helperText}
|
||||
asterisk={asterisk}
|
||||
size={labelSize}
|
||||
palette={palette}
|
||||
/>
|
||||
<Incrementer {...incrementerProps} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
IncrementerBlockComponent.displayName = "IncrementerBlock";
|
||||
|
||||
export default memo(IncrementerBlockComponent);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./Incrementer";
|
||||
export { default as Incrementer } from "./Incrementer";
|
||||
export { default as IncrementerBlock } from "./IncrementerBlock";
|
||||
export type { IncrementerProps } from "./Incrementer";
|
||||
export type { IncrementerBlockProps } from "./IncrementerBlock";
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared "Applicable Scope" field used by the `decision-approaches` and
|
||||
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
|
||||
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
|
||||
* that reveals a pill text input for creating new scope values.
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import Chip from "../../components/controls/Chip";
|
||||
import InputLabel from "../../components/utility/InputLabel";
|
||||
|
||||
export interface ApplicableScopeFieldProps {
|
||||
/** Label rendered above the capsule row. */
|
||||
label: string;
|
||||
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
|
||||
addLabel: string;
|
||||
/**
|
||||
* The full list of chip values shown to the user. Each value is a unique
|
||||
* string (chip label).
|
||||
*/
|
||||
scopes: string[];
|
||||
/** Values currently toggled on (rendered in the Chip "Selected" state). */
|
||||
selectedScopes: string[];
|
||||
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
|
||||
onToggleScope: (_scope: string) => void;
|
||||
/**
|
||||
* Fired when the user submits a new scope via the inline input. Duplicate
|
||||
* values (already in `scopes`) are filtered out before the callback fires.
|
||||
*/
|
||||
onAddScope: (_scope: string) => void;
|
||||
/**
|
||||
* Optional placeholder for the inline input. Defaults to `addLabel`.
|
||||
*/
|
||||
inputPlaceholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ApplicableScopeFieldComponent({
|
||||
label,
|
||||
addLabel,
|
||||
scopes,
|
||||
selectedScopes,
|
||||
onToggleScope,
|
||||
onAddScope,
|
||||
inputPlaceholder,
|
||||
className = "",
|
||||
}: ApplicableScopeFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const submitDraft = () => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) {
|
||||
setIsAdding(false);
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
if (!scopes.includes(trimmed)) {
|
||||
onAddScope(trimmed);
|
||||
}
|
||||
setDraft("");
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{scopes.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope);
|
||||
return (
|
||||
<Chip
|
||||
key={scope}
|
||||
label={scope}
|
||||
state={isSelected ? "Selected" : "Disabled"}
|
||||
palette="Default"
|
||||
size="S"
|
||||
disabled={false}
|
||||
onClick={() => onToggleScope(scope)}
|
||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAdding ? (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={submitDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitDraft();
|
||||
} else if (e.key === "Escape") {
|
||||
setDraft("");
|
||||
setIsAdding(false);
|
||||
}
|
||||
}}
|
||||
placeholder={inputPlaceholder ?? addLabel}
|
||||
aria-label={inputPlaceholder ?? addLabel}
|
||||
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||
>
|
||||
<AddGlyph />
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddGlyph() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
className="block size-[14px]"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
|
||||
|
||||
export default memo(ApplicableScopeFieldComponent);
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared "labelled text area" field used by every create flow modal section.
|
||||
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
|
||||
* appearance — matching the Figma "Control / Text Area" pattern.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import TextArea from "../../components/controls/TextArea";
|
||||
import InputLabel from "../../components/utility/InputLabel";
|
||||
|
||||
export interface ModalTextAreaFieldProps {
|
||||
/** Label rendered above the text area. */
|
||||
label: string;
|
||||
/** Show the help "?" icon next to the label (default `true`). */
|
||||
helpIcon?: boolean;
|
||||
/** Current text value. */
|
||||
value: string;
|
||||
/** Fired on every change with the new value (no event). */
|
||||
onChange: (_value: string) => void;
|
||||
/** Optional rows for the underlying `<textarea>` (default 4). */
|
||||
rows?: number;
|
||||
/** Optional placeholder. */
|
||||
placeholder?: string;
|
||||
/** Disable the field. */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ModalTextAreaFieldComponent({
|
||||
label,
|
||||
helpIcon = true,
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: ModalTextAreaFieldProps) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||
<InputLabel label={label} helpIcon={helpIcon} size="s" palette="default" />
|
||||
<TextArea
|
||||
formHeader={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="large"
|
||||
rows={rows}
|
||||
appearance="embedded"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
|
||||
|
||||
export default memo(ModalTextAreaFieldComponent);
|
||||
@@ -14,7 +14,7 @@ import { FinalReviewScreen } from "./review/FinalReviewScreen";
|
||||
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
|
||||
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
|
||||
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
|
||||
import { RightRailScreen } from "./right-rail/RightRailScreen";
|
||||
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
|
||||
import { CompletedScreen } from "./completed/CompletedScreen";
|
||||
|
||||
/**
|
||||
@@ -76,7 +76,7 @@ export function CreateFlowScreenView({
|
||||
case "membership-methods":
|
||||
return <MembershipMethodsScreen />;
|
||||
case "decision-approaches":
|
||||
return <RightRailScreen />;
|
||||
return <DecisionApproachesScreen />;
|
||||
case "conflict-management":
|
||||
return <ConflictManagementScreen />;
|
||||
case "confirm-stakeholders":
|
||||
|
||||
@@ -16,23 +16,18 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import TextArea from "../../../components/controls/TextArea";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
|
||||
const IN_PERSON_CARD_ID = "in-person-meetings";
|
||||
const SIGNAL_CARD_ID = "signal";
|
||||
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
|
||||
|
||||
const ADD_PLATFORM_CARD_IDS = [
|
||||
IN_PERSON_CARD_ID,
|
||||
SIGNAL_CARD_ID,
|
||||
VIDEO_MEETINGS_CARD_ID,
|
||||
] as const;
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
@@ -50,40 +45,6 @@ const COMMUNICATION_CARD_ORDER = [
|
||||
"7",
|
||||
] as const;
|
||||
|
||||
function CreateModalSection({
|
||||
title,
|
||||
value: _value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
onChange: (_value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
|
||||
aria-hidden
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</div>
|
||||
<TextArea
|
||||
formHeader={false}
|
||||
value={_value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
size="large"
|
||||
rows={6}
|
||||
appearance="embedded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPlatformModalContent({
|
||||
platformCardId,
|
||||
}: {
|
||||
@@ -92,15 +53,15 @@ function AddPlatformModalContent({
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
|
||||
const defaults =
|
||||
modal && "sections" in modal
|
||||
? modal.sections
|
||||
: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
};
|
||||
const modal =
|
||||
platformCardId in comm.modals
|
||||
? comm.modals[platformCardId as keyof typeof comm.modals]
|
||||
: null;
|
||||
const defaults = modal?.sections ?? {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
};
|
||||
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionField, string>
|
||||
@@ -118,14 +79,13 @@ function AddPlatformModalContent({
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
if (!modal || !("sections" in modal)) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<CreateModalSection
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
title={comm.sectionHeadings[field]}
|
||||
label={comm.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
@@ -134,13 +94,6 @@ function AddPlatformModalContent({
|
||||
);
|
||||
}
|
||||
|
||||
function isAddPlatformCard(cardId: string | null): boolean {
|
||||
return (
|
||||
cardId !== null &&
|
||||
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
|
||||
);
|
||||
}
|
||||
|
||||
export function CommunicationMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.communication;
|
||||
@@ -180,42 +133,55 @@ export function CommunicationMethodsScreen() {
|
||||
) : (
|
||||
<>
|
||||
{comm.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{comm.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
</InlineTextButton>
|
||||
{comm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in comm.modals
|
||||
? (() => {
|
||||
const modal =
|
||||
comm.modals[pendingCardId as keyof typeof comm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (pendingCardId in comm.modals) {
|
||||
const modal = comm.modals[pendingCardId as keyof typeof comm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const cardRow =
|
||||
pendingCardId in comm.cards
|
||||
? comm.cards[pendingCardId as keyof typeof comm.cards]
|
||||
: null;
|
||||
return {
|
||||
title: cardRow?.label ?? comm.confirmModal.title,
|
||||
description: cardRow?.supportText ?? comm.confirmModal.description,
|
||||
nextButtonText: comm.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
@@ -286,8 +252,9 @@ export function CommunicationMethodsScreen() {
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
|
||||
{pendingCardId ? (
|
||||
<AddPlatformModalContent
|
||||
key={pendingCardId}
|
||||
platformCardId={pendingCardId}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
/**
|
||||
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
|
||||
*
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
|
||||
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
|
||||
* & Fallbacks. Section defaults are sourced from
|
||||
* `messages/en/create/conflictManagement.json` and will be replaced with DB-driven
|
||||
* content; labels are hard-coded per the Figma design.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
@@ -12,11 +18,14 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
import ApplicableScopeField from "../../components/ApplicableScopeField";
|
||||
|
||||
const CONFLICT_CARD_ORDER = [
|
||||
"peer-mediation",
|
||||
@@ -29,6 +38,92 @@ const CONFLICT_CARD_ORDER = [
|
||||
"8",
|
||||
] as const;
|
||||
|
||||
type ConflictModalSections = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
processProtocol: string;
|
||||
restorationFallbacks: string;
|
||||
};
|
||||
|
||||
function AddConflictApproachModalContent({
|
||||
approachCardId,
|
||||
}: {
|
||||
approachCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const cm = m.create.conflictManagement;
|
||||
const modal =
|
||||
approachCardId in cm.modals
|
||||
? cm.modals[approachCardId as keyof typeof cm.modals]
|
||||
: null;
|
||||
const modalSections = modal?.sections;
|
||||
const defaults: ConflictModalSections = {
|
||||
corePrinciple: modalSections?.corePrinciple ?? "",
|
||||
applicableScope: modalSections?.applicableScope ?? [],
|
||||
selectedApplicableScope: [],
|
||||
processProtocol: modalSections?.processProtocol ?? "",
|
||||
restorationFallbacks: modalSections?.restorationFallbacks ?? "",
|
||||
};
|
||||
|
||||
const [sections, setSections] = useState<ConflictModalSections>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
applicableScope: [...defaults.applicableScope],
|
||||
selectedApplicableScope: [...defaults.selectedApplicableScope],
|
||||
processProtocol: defaults.processProtocol,
|
||||
restorationFallbacks: defaults.restorationFallbacks,
|
||||
}));
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof ConflictModalSections>(
|
||||
key: K,
|
||||
value: ConflictModalSections[K],
|
||||
) => {
|
||||
markCreateFlowInteraction();
|
||||
setSections((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.corePrinciple}
|
||||
value={sections.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={cm.sectionHeadings.applicableScope}
|
||||
addLabel={cm.scopeAddButtonLabel}
|
||||
scopes={sections.applicableScope}
|
||||
selectedScopes={sections.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
sections.selectedApplicableScope.includes(scope)
|
||||
? sections.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...sections.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...sections.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.processProtocol}
|
||||
value={sections.processProtocol}
|
||||
onChange={(v) => patch("processProtocol", v)}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={cm.sectionHeadings.restorationFallbacks}
|
||||
value={sections.restorationFallbacks}
|
||||
onChange={(v) => patch("restorationFallbacks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConflictManagementScreen() {
|
||||
const m = useMessages();
|
||||
const cm = m.create.conflictManagement;
|
||||
@@ -68,41 +163,55 @@ export function ConflictManagementScreen() {
|
||||
) : (
|
||||
<>
|
||||
{cm.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{cm.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
</InlineTextButton>
|
||||
{cm.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in cm.modals
|
||||
? (() => {
|
||||
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (pendingCardId in cm.modals) {
|
||||
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: cm.addApproach.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const cardRow =
|
||||
pendingCardId in cm.cards
|
||||
? cm.cards[pendingCardId as keyof typeof cm.cards]
|
||||
: null;
|
||||
return {
|
||||
title: cardRow?.label ?? cm.confirmModal.title,
|
||||
description: cardRow?.supportText ?? cm.confirmModal.description,
|
||||
nextButtonText: cm.addApproach.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
@@ -173,7 +282,15 @@ export function ConflictManagementScreen() {
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
/>
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddConflictApproachModalContent
|
||||
key={pendingCardId}
|
||||
approachCardId={pendingCardId}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
/**
|
||||
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
|
||||
*
|
||||
* Card click opens the Figma create modal (node `20858-13948`) with three
|
||||
* editable sections — Eligibility & Philosophy, Joining Process, and
|
||||
* Expectations & Removal. Section defaults come from
|
||||
* `messages/en/create/membership.json` and will be replaced with DB-driven
|
||||
* content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
@@ -12,11 +18,20 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||
import {
|
||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
|
||||
const SECTION_FIELDS = [
|
||||
"eligibility",
|
||||
"joiningProcess",
|
||||
"expectations",
|
||||
] as const;
|
||||
type SectionField = (typeof SECTION_FIELDS)[number];
|
||||
|
||||
const MEMBERSHIP_CARD_ORDER = [
|
||||
"open-access",
|
||||
@@ -29,6 +44,55 @@ const MEMBERSHIP_CARD_ORDER = [
|
||||
"8",
|
||||
] as const;
|
||||
|
||||
function AddMembershipModalContent({
|
||||
membershipCardId,
|
||||
}: {
|
||||
membershipCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const mem = m.create.membership;
|
||||
const modal =
|
||||
membershipCardId in mem.modals
|
||||
? mem.modals[membershipCardId as keyof typeof mem.modals]
|
||||
: null;
|
||||
const defaults = modal?.sections ?? {
|
||||
eligibility: "",
|
||||
joiningProcess: "",
|
||||
expectations: "",
|
||||
};
|
||||
|
||||
const [sectionValues, setSectionValues] = useState<
|
||||
Record<SectionField, string>
|
||||
>(() => ({
|
||||
eligibility: defaults.eligibility,
|
||||
joiningProcess: defaults.joiningProcess,
|
||||
expectations: defaults.expectations,
|
||||
}));
|
||||
|
||||
const updateSection = useCallback(
|
||||
(key: SectionField, value: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSectionValues((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{SECTION_FIELDS.map((field) => (
|
||||
<ModalTextAreaField
|
||||
key={field}
|
||||
label={mem.sectionHeadings[field]}
|
||||
rows={6}
|
||||
value={sectionValues[field]}
|
||||
onChange={(v) => updateSection(field, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembershipMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const mem = m.create.membership;
|
||||
@@ -68,41 +132,55 @@ export function MembershipMethodsScreen() {
|
||||
) : (
|
||||
<>
|
||||
{mem.page.compactDescriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{mem.page.compactDescriptionLinkLabel}
|
||||
</button>
|
||||
</InlineTextButton>
|
||||
{mem.page.compactDescriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig =
|
||||
pendingCardId && pendingCardId in mem.modals
|
||||
? (() => {
|
||||
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (pendingCardId in mem.modals) {
|
||||
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: mem.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const cardRow =
|
||||
pendingCardId in mem.cards
|
||||
? mem.cards[pendingCardId as keyof typeof mem.cards]
|
||||
: null;
|
||||
return {
|
||||
title: cardRow?.label ?? mem.confirmModal.title,
|
||||
description: cardRow?.supportText ?? mem.confirmModal.description,
|
||||
nextButtonText: mem.addPlatform.nextButtonText,
|
||||
showBackButton: false as const,
|
||||
currentStep: undefined,
|
||||
totalSteps: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
@@ -173,7 +251,15 @@ export function MembershipMethodsScreen() {
|
||||
showBackButton={modalConfig.showBackButton}
|
||||
currentStep={modalConfig.currentStep}
|
||||
totalSteps={modalConfig.totalSteps}
|
||||
/>
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddMembershipModalContent
|
||||
key={pendingCardId}
|
||||
membershipCardId={pendingCardId}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowStepShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
|
||||
*
|
||||
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
|
||||
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
|
||||
*
|
||||
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
|
||||
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
|
||||
* Deadlocks. Section defaults are sourced from `messages/en/create/rightRail.json` and will be
|
||||
* replaced with DB-driven content; labels are hard-coded per the Figma design.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import { IncrementerBlock } from "../../../components/controls/Incrementer";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import ModalTextAreaField from "../../components/ModalTextAreaField";
|
||||
import ApplicableScopeField from "../../components/ApplicableScopeField";
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
const CONSENSUS_LEVEL_MAX = 100;
|
||||
const CONSENSUS_LEVEL_STEP = 5;
|
||||
const CONSENSUS_LEVEL_DEFAULT = 75;
|
||||
|
||||
type RightRailModalSections = {
|
||||
corePrinciple: string;
|
||||
applicableScope: string[];
|
||||
selectedApplicableScope: string[];
|
||||
stepByStepInstructions: string;
|
||||
consensusLevel: number;
|
||||
objectionsDeadlocks: string;
|
||||
};
|
||||
|
||||
function AddDecisionApproachModalContent({
|
||||
approachCardId,
|
||||
}: {
|
||||
approachCardId: string;
|
||||
}) {
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const modal =
|
||||
approachCardId in rr.modals
|
||||
? rr.modals[approachCardId as keyof typeof rr.modals]
|
||||
: null;
|
||||
const modalSections = modal?.sections;
|
||||
const defaults: RightRailModalSections = {
|
||||
corePrinciple: modalSections?.corePrinciple ?? "",
|
||||
applicableScope: modalSections?.applicableScope ?? [],
|
||||
selectedApplicableScope: [],
|
||||
stepByStepInstructions: modalSections?.stepByStepInstructions ?? "",
|
||||
consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT,
|
||||
objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "",
|
||||
};
|
||||
|
||||
const [sections, setSections] = useState<RightRailModalSections>(() => ({
|
||||
corePrinciple: defaults.corePrinciple,
|
||||
applicableScope: [...defaults.applicableScope],
|
||||
selectedApplicableScope: [...defaults.selectedApplicableScope],
|
||||
stepByStepInstructions: defaults.stepByStepInstructions,
|
||||
consensusLevel: defaults.consensusLevel,
|
||||
objectionsDeadlocks: defaults.objectionsDeadlocks,
|
||||
}));
|
||||
|
||||
const patch = useCallback(
|
||||
<K extends keyof RightRailModalSections>(
|
||||
key: K,
|
||||
value: RightRailModalSections[K],
|
||||
) => {
|
||||
markCreateFlowInteraction();
|
||||
setSections((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ModalTextAreaField
|
||||
label={rr.sectionHeadings.corePrinciple}
|
||||
value={sections.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={rr.sectionHeadings.applicableScope}
|
||||
addLabel={rr.scopeAddButtonLabel}
|
||||
scopes={sections.applicableScope}
|
||||
selectedScopes={sections.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
sections.selectedApplicableScope.includes(scope)
|
||||
? sections.selectedApplicableScope.filter((s) => s !== scope)
|
||||
: [...sections.selectedApplicableScope, scope],
|
||||
)
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
patch("applicableScope", [...sections.applicableScope, scope])
|
||||
}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={rr.sectionHeadings.stepByStepInstructions}
|
||||
value={sections.stepByStepInstructions}
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={rr.sectionHeadings.consensusLevel}
|
||||
value={sections.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => patch("consensusLevel", next)}
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={rr.sectionHeadings.objectionsDeadlocks}
|
||||
value={sections.objectionsDeadlocks}
|
||||
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionApproachesScreen() {
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedDecisionApproachIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
rr.messageBox.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})),
|
||||
[rr.messageBox.items],
|
||||
);
|
||||
|
||||
const sampleCards: CardStackItem[] = useMemo(
|
||||
() =>
|
||||
rr.cards.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
supportText: c.supportText,
|
||||
recommended: c.recommended,
|
||||
})),
|
||||
[rr.cards],
|
||||
);
|
||||
|
||||
const cardById = useMemo(
|
||||
() => new Map(rr.cards.map((c) => [c.id, c])),
|
||||
[rr.cards],
|
||||
);
|
||||
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{rr.sidebar.descriptionBefore}
|
||||
<InlineTextButton
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{rr.sidebar.descriptionLinkLabel}
|
||||
</InlineTextButton>
|
||||
{rr.sidebar.descriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
markCreateFlowInteraction();
|
||||
setMessageBoxCheckedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setPendingCardId(id);
|
||||
setCreateModalOpen(true);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateModalConfirm = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (pendingCardId) {
|
||||
setSelectedIds(
|
||||
selectedIds.includes(pendingCardId)
|
||||
? selectedIds
|
||||
: [...selectedIds, pendingCardId],
|
||||
);
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
|
||||
|
||||
const modalConfig = (() => {
|
||||
if (!pendingCardId) {
|
||||
return {
|
||||
title: rr.confirmModal.title,
|
||||
description: rr.confirmModal.description,
|
||||
nextButtonText: rr.confirmModal.nextButtonText,
|
||||
};
|
||||
}
|
||||
|
||||
if (pendingCardId in rr.modals) {
|
||||
const modal = rr.modals[pendingCardId as keyof typeof rr.modals];
|
||||
return {
|
||||
title: modal.title,
|
||||
description: modal.description,
|
||||
nextButtonText: rr.addApproach.nextButtonText,
|
||||
};
|
||||
}
|
||||
|
||||
const card = cardById.get(pendingCardId);
|
||||
return {
|
||||
title: card?.label ?? rr.confirmModal.title,
|
||||
description: card?.supportText ?? rr.confirmModal.description,
|
||||
nextButtonText: rr.addApproach.nextButtonText,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
contentTopBelowMd="space-800"
|
||||
lgVerticalAlign="start"
|
||||
header={
|
||||
<DecisionMakingSidebar
|
||||
title={rr.sidebar.title}
|
||||
description={sidebarDescription}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
compactRecommendedLimit={5}
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCreateModalClose}
|
||||
onNext={handleCreateModalConfirm}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
backdropVariant="loginYellow"
|
||||
>
|
||||
{pendingCardId ? (
|
||||
<AddDecisionApproachModalContent
|
||||
key={pendingCardId}
|
||||
approachCardId={pendingCardId}
|
||||
/>
|
||||
) : null}
|
||||
</Create>
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
|
||||
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
|
||||
*
|
||||
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
|
||||
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
||||
import CardStack from "../../../components/utility/CardStack";
|
||||
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
|
||||
export function RightRailScreen() {
|
||||
const m = useMessages();
|
||||
const rr = m.create.rightRail;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
const setSelectedIds = useCallback(
|
||||
(next: string[]) => {
|
||||
updateState({ selectedDecisionApproachIds: next });
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||
() =>
|
||||
rr.messageBox.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
})),
|
||||
[rr.messageBox.items],
|
||||
);
|
||||
|
||||
const sampleCards: CardStackItem[] = useMemo(
|
||||
() =>
|
||||
rr.cards.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
supportText: c.supportText,
|
||||
recommended: c.recommended,
|
||||
})),
|
||||
[rr.cards],
|
||||
);
|
||||
|
||||
const sidebarDescription = (
|
||||
<>
|
||||
{rr.sidebar.descriptionBefore}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
|
||||
onClick={() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
{rr.sidebar.descriptionLinkLabel}
|
||||
</button>
|
||||
{rr.sidebar.descriptionAfter}
|
||||
</>
|
||||
);
|
||||
|
||||
const handleMessageBoxCheckboxChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
markCreateFlowInteraction();
|
||||
setMessageBoxCheckedIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((x) => x !== id),
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
setSelectedIds(
|
||||
selectedIds.includes(id)
|
||||
? selectedIds.filter((x) => x !== id)
|
||||
: [...selectedIds, id],
|
||||
);
|
||||
},
|
||||
[markCreateFlowInteraction, selectedIds, setSelectedIds],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
return (
|
||||
<CreateFlowTwoColumnSelectShell
|
||||
contentTopBelowMd="space-800"
|
||||
lgVerticalAlign="start"
|
||||
header={
|
||||
<DecisionMakingSidebar
|
||||
title={rr.sidebar.title}
|
||||
description={sidebarDescription}
|
||||
messageBoxTitle={rr.messageBox.title}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
|
||||
size={mdUp ? "L" : "M"}
|
||||
justification={mdUp ? "left" : "center"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
|
||||
<CardStack
|
||||
cards={sampleCards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={true}
|
||||
toggleLabel={rr.cardStack.toggleSeeAll}
|
||||
showLessLabel={rr.cardStack.toggleShowLess}
|
||||
title=""
|
||||
description=""
|
||||
layout="singleStack"
|
||||
compactRecommendedLimit={5}
|
||||
className="w-full"
|
||||
headerLockupSize={mdUp ? "L" : "M"}
|
||||
/>
|
||||
</div>
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
# Template Recommendation Matrix — Implementation Context (CR-88)
|
||||
|
||||
**Status:** Draft / context doc. Reference only — not yet implemented.
|
||||
**Linear:** [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)
|
||||
**Roadmap:** [`docs/backend-roadmap.md`](backend-roadmap.md) §4 (`RuleTemplate`) and §13.
|
||||
**Spec ticket:** [`docs/backend-linear-tickets.md`](backend-linear-tickets.md) Ticket 16.
|
||||
|
||||
This doc consolidates the **four product-authored matrix spreadsheets** with the
|
||||
**existing data model, create-flow facets, and section structure** so we have a
|
||||
single reference while implementing the importer + recommendation API.
|
||||
|
||||
> **Scope note:** No data, API, or UI surface for this feature is in production
|
||||
> yet. **Backwards compatibility is not a constraint** — we will replace the
|
||||
> hand-typed `prisma/seed.ts` `COMPOSITION_BY_SLUG` map, the existing
|
||||
> `GET /api/templates` response shape, and the static `messages/en/create/*.json`
|
||||
> card decks where it makes the design cleaner.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal (one paragraph)
|
||||
|
||||
Replace the hand-curated `prisma/seed.ts` `COMPOSITION_BY_SLUG` map with a
|
||||
**spreadsheet-authored matrix** for each rule section
|
||||
(Communication, Membership, Decision-making, Conflict management — and later
|
||||
Values), where each row is a **method/pattern card** and each column is either
|
||||
**long-form copy that populates the card UI** or a **facet flag** (✓/x or score)
|
||||
that the recommendation engine uses to filter and rank cards based on the
|
||||
user's create-flow answers (community size, organization type, location/scale,
|
||||
maturity).
|
||||
|
||||
The same authoring contract should make it trivial for product to ship updated
|
||||
spreadsheets and have the create-flow card decks (and the home/templates page
|
||||
recommendations) update without any code changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. The four spreadsheets
|
||||
|
||||
All four xlsx files share **the same column shape**: leading **content
|
||||
columns** + trailing **facet columns** (✓ / x cells). Sheet name is `Current`
|
||||
in every workbook.
|
||||
|
||||
### 2.1 Shared facet columns (last 19, identical across the four sheets)
|
||||
|
||||
Order is preserved here because the columns are positional in the sheets:
|
||||
|
||||
| # | Column header (xlsx) | Maps to wizard step / state field | Wizard chip label (`messages/en/create/...`) |
|
||||
|---|---|---|---|
|
||||
| 1 | `1 member` | `community-size` → `selectedCommunitySizeIds` (id `"1"`) | `1 member` |
|
||||
| 2 | `2-5 members` | id `"2"` | `2-5 members` |
|
||||
| 3 | `6-12 members` | id `"3"` | `6-12 members` |
|
||||
| 4 | `13-100 members` | id `"4"` | `13-100 members` |
|
||||
| 5 | `100-100,000 members` | id `"5"` | `100-100,000 members` |
|
||||
| 6 | `Organization Type:DAO` (or `DAO` in conflict/comms/membership) | `community-structure` → `selectedOrganizationTypeIds` (id `"6"` in `organizationTypes`) | `DAO` |
|
||||
| 7 | `Organization Type:For profit business` (or `For profit business`) | id `"5"` in `organizationTypes` | `For profit business` |
|
||||
| 8 | `Organization Type:Nonprofit` (or `Nonprofit`) | id `"4"` in `organizationTypes` | `Nonprofit` |
|
||||
| 9 | `Organization Type:Open source project` (or `Open source project`) | id `"3"` | `Open source project` |
|
||||
| 10 | `Organization Type:Mutual aid` (or `Mutual aid`) | id `"2"` | `Mutual aid` |
|
||||
| 11 | `Organization Type: Worker’s coop` (or `Worker’s coop`) | id `"1"` | `Worker’s coop` |
|
||||
| 12 | `Location: Global` (or `Global`) | `community-structure` → `selectedScaleIds` (id `"4"` in `scaleOptions`) | `Global` |
|
||||
| 13 | `Location: National` (or `National`) | id `"3"` | `National` |
|
||||
| 14 | `Location: Regional` (or `Regional`) | id `"2"` | `Regional` |
|
||||
| 15 | `Location: Local` (or `Local`) | id `"1"` | `Local` |
|
||||
| 16 | `Organizational Maturity: Early stage` (or `Early stage`) | `community-structure` → `selectedMaturityIds` (id `"1"` in `maturityOptions`) | `Early stage` |
|
||||
| 17 | `Organizational Maturity: Growth stage` (or `Growth stage`) | id `"2"` | `Growth stage` |
|
||||
| 18 | `Organizational Maturity: Established` (or `Established`) | id `"3"` | `Established` |
|
||||
| 19 | `Organizational Maturity: Enterprise` (or `Enterprise`) | id `"4"` | `Enterprise` |
|
||||
|
||||
**Important normalization rules (importer must enforce):**
|
||||
|
||||
- Decision-making prefixes columns with `Organization Type:`, `Location:`,
|
||||
`Organizational Maturity:`. The other three sheets drop the prefix. Importer
|
||||
should normalize to a single canonical key (e.g.
|
||||
`orgType.workersCoop`, `scale.local`, `maturity.earlyStage`, `size.6_12`).
|
||||
- Cell value semantics: `✓` → match, `x` (lowercase) → no match, blank → no
|
||||
match, numbers → optional weighted score (only `Decision-making.xlsx` row 32
|
||||
contains a non-symbol cell — `"Military, Corporations"` in the size column —
|
||||
see §2.4 data-quality issues).
|
||||
- Wizard chip ids are **positional 1..N** within each `messages/en/create/*`
|
||||
array (see `chipRowsFromLabels` in
|
||||
`app/create/screens/select/CommunityStructureSelectScreen.tsx` lines 49–57).
|
||||
The importer should emit a stable lookup table mapping
|
||||
`(facetGroup, label) → wizardChipId` so the recommendation engine can match
|
||||
a user's `selectedXxxIds` against the matrix without depending on label
|
||||
spelling.
|
||||
- Curly apostrophes appear in `Worker’s coop`. Compare on a normalized key,
|
||||
not on raw label.
|
||||
|
||||
### 2.2 Communication Methods (`Communication Methods.xlsx`, sheet `Current`)
|
||||
|
||||
Maps 1:1 to `messages/en/create/communication.json` and the
|
||||
`communication-methods` step
|
||||
(`app/create/screens/card/CommunicationMethodsScreen.tsx`).
|
||||
|
||||
**Content columns (positions 1–5):**
|
||||
|
||||
| Sheet column | Card field |
|
||||
|---|---|
|
||||
| `Label` | `cards[<id>].label` and `modals[<id>].title` |
|
||||
| `Description` | `cards[<id>].supportText` and `modals[<id>].description` |
|
||||
| `Core Principle & Scope` | `modals[<id>].sections.corePrinciple` |
|
||||
| `Logistics, Admin & Norms` | `modals[<id>].sections.logisticsAdmin` |
|
||||
| `Code of Conduct` | `modals[<id>].sections.codeOfConduct` |
|
||||
|
||||
`SECTION_FIELDS = ["corePrinciple", "logisticsAdmin", "codeOfConduct"]` is
|
||||
the source of truth (`CommunicationMethodsScreen.tsx`).
|
||||
|
||||
**Card rows (11):** In-Person Meetings · Signal · Video Meetings · Loomio ·
|
||||
Matrix / Element · GitHub / GitLab · Discord · Email Distribution List · Slack
|
||||
· WhatsApp · Discourse (Forum).
|
||||
|
||||
### 2.3 Membership / Group-Membership (`Group_Membership_Methods.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `membership-methods` step
|
||||
(`app/create/screens/card/MembershipMethodsScreen.tsx`) and
|
||||
`messages/en/create/membership.json`.
|
||||
|
||||
**Content columns (positions 1–5):**
|
||||
|
||||
| Sheet column | Card field (proposed naming) |
|
||||
|---|---|
|
||||
| `Label` | `cards[<id>].label` / modal title |
|
||||
| `Description` | `cards[<id>].supportText` / modal description |
|
||||
| `Eligibility & Philosophy` | modal section A (`eligibilityPhilosophy`) |
|
||||
| `Joining Process` | modal section B (`joiningProcess`) |
|
||||
| `Expectations & Removal` | modal section C (`expectationsRemoval`) |
|
||||
|
||||
**Card rows (19):** Open Access · Orientation Required · Invitation Only ·
|
||||
Contribution Based · Mentorship · Peer Sponsorship · Consensus or Vote-Based
|
||||
Approval · Trial Period / Provisional Membership · Referral System with
|
||||
Screening · Membership Agreement or Pledge · Weighted or Tiered Membership ·
|
||||
Hybrid Approval Process · Skill-Based Contribution · Pay-to-Join · Application
|
||||
& Review · Identity Verification · Collective Interviews · Skill-Based
|
||||
Evaluation · Lottery / Sortition.
|
||||
|
||||
> The wizard's existing `membership.json` modal section keys do not yet match
|
||||
> these. Since backwards compatibility is not a constraint, **rename the
|
||||
> wizard's section keys to match the matrix** (`eligibilityPhilosophy` /
|
||||
> `joiningProcess` / `expectationsRemoval`) when wiring this up — the existing
|
||||
> copy is placeholder.
|
||||
|
||||
### 2.4 Decision-making (`Decision-making.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `decision-approaches` step
|
||||
(`app/create/screens/right-rail/DecisionApproachesScreen.tsx`) and
|
||||
`messages/en/create/rightRail.json`.
|
||||
|
||||
**Content columns (positions 1–7):**
|
||||
|
||||
| Sheet column | Card field (proposed naming) |
|
||||
|---|---|
|
||||
| `Label` | card title |
|
||||
| `Description` | card support text |
|
||||
| `Core Principle` | modal section A (`corePrinciple`) |
|
||||
| `Applicable Scope` | modal section B (`applicableScope`) — free-text examples, e.g. `"Daily Operations, Minor Expenditures"` |
|
||||
| `Consensus Level` | numeric 0.0–1.0 stored under `scalars.consensusLevel` (e.g. `0.51`, `0.67`, `1.0`) — drives the **Consensus axis** in any future visual sort/filter |
|
||||
| `Step-by-Step Instructions` | modal section C (`stepByStep`) |
|
||||
| `Objections & Deadlocks` | modal section D (`objectionsDeadlocks`) |
|
||||
|
||||
**Card rows (32):** Lazy Consensus · Do-ocracy · Consensus Decision-Making ·
|
||||
Rotational Leadership · Modified Consensus · Consensus Seeking with Delegates
|
||||
· Sociocracy · Supermajority Rule · Ranked Choice Voting · Range Voting ·
|
||||
Majority Rule · Approval Voting · Weighted Voting · Cumulative Voting ·
|
||||
Quadratic Voting · Continuous Voting · Holacracy · Collaborative Platforms ·
|
||||
Deliberative Polling · Investor-Filled Board Seats · Elected Board of
|
||||
Directors · Advisory Committees · Delegated Decision-Making · Executive
|
||||
Committees · First Past the Post · Lottery/Sortition · Proof of Work · Random
|
||||
Choice · Algorithm-Driven Decisions · Autocratic Decision-Making ·
|
||||
Hierarchical Decision-Making · Negotiated Decisions.
|
||||
|
||||
**Data-quality issues to handle in the importer (do not silently drop):**
|
||||
|
||||
- Row 32 (`Hierarchical Decision-Making`): the `Consensus Level` cell contains
|
||||
`"Military, Corporations"` (the value clearly belongs to `Applicable Scope`,
|
||||
which itself already contains `"Military, Corporations"`). Importer should
|
||||
flag this as a validation error and require a fix in the source workbook
|
||||
rather than try to repair it.
|
||||
- Row 11 (`Range Voting`): the **last facet column** (`Maturity: Enterprise`)
|
||||
is empty in the source — treat empty as `x` (no match) **only after** the
|
||||
importer logs a warning so the author knows it wasn't intentional ✓.
|
||||
|
||||
### 2.5 Conflict Management (`Conflict Management Methods.xlsx`, sheet `Current`)
|
||||
|
||||
Maps to the `conflict-management` step
|
||||
(`app/create/screens/card/ConflictManagementScreen.tsx`) and
|
||||
`messages/en/create/conflictManagement.json`.
|
||||
|
||||
**Content columns (positions 1–6):**
|
||||
|
||||
| Sheet column | Card field (proposed naming) |
|
||||
|---|---|
|
||||
| `Title` | card title (note: not `Label` like the other three) |
|
||||
| `Description` | card support text |
|
||||
| `Core Principle` | modal section A (`corePrinciple`) |
|
||||
| `Applicable Scope` | modal section B (`applicableScope`) |
|
||||
| `Process Protocol` | modal section C (`processProtocol`) |
|
||||
| `Restoration & Fallbacks` | modal section D (`restorationFallbacks`) |
|
||||
|
||||
**Card rows (19):** Peer Mediation · Conflict Resolution Council · Facilitated
|
||||
Negotiation · Ad Hoc Arbitration · Conflict Workshops · Supermajority Vote ·
|
||||
Interest-Based Bargaining · Restorative Practices · Mediation · Circle
|
||||
Processes · Judicial Committees · Managerial Decision · Internal Tribunal ·
|
||||
Consensus Building · Binding Arbitration · Non-Binding Arbitration · Binding
|
||||
Contracts · Lottery/Sortition · Rotational Judging.
|
||||
|
||||
> Conflict Management sheet uses `Title` instead of `Label` and omits the
|
||||
> `Organization Type:` / `Location:` / `Organizational Maturity:` prefixes —
|
||||
> normalize both at import time.
|
||||
|
||||
---
|
||||
|
||||
## 3. Existing data model & wizard surface area
|
||||
|
||||
### 3.1 `RuleTemplate` (today)
|
||||
|
||||
```64:73:prisma/schema.prisma
|
||||
model RuleTemplate {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
category String?
|
||||
description String?
|
||||
body Json
|
||||
sortOrder Int @default(0)
|
||||
featured Boolean @default(false)
|
||||
}
|
||||
```
|
||||
|
||||
`body` JSON is the rendered rule document
|
||||
(`{ sections: [{ categoryName, entries: [{ title, body }] }, ...] }`),
|
||||
authored today by the `bodyFromXlsxComposition()` helper in
|
||||
`prisma/seed.ts` from a hand-typed `COMPOSITION_BY_SLUG` map.
|
||||
|
||||
**Section ordering (canonical):** Values → Communication → Membership →
|
||||
Decision-making → Conflict management. Final-review and `governancePatternBody`
|
||||
both rely on this exact order and casing.
|
||||
|
||||
```16:60:prisma/seed.ts
|
||||
function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
|
||||
return {
|
||||
sections: [
|
||||
{ categoryName: "Values", entries: [{ title: "Core stance", body: coreValues }] },
|
||||
{ categoryName: "Communication", entries: [...] },
|
||||
{ categoryName: "Membership", entries: [...] },
|
||||
{ categoryName: "Decision-making", entries: [...] },
|
||||
{ categoryName: "Conflict management", entries: [...] },
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Wizard facets captured today (`CreateFlowState`)
|
||||
|
||||
```83:95:app/create/types.ts
|
||||
selectedCommunitySizeIds?: string[];
|
||||
selectedOrganizationTypeIds?: string[];
|
||||
selectedScaleIds?: string[];
|
||||
selectedMaturityIds?: string[];
|
||||
selectedCoreValueIds?: string[];
|
||||
selectedCommunicationMethodIds?: string[];
|
||||
selectedMembershipMethodIds?: string[];
|
||||
selectedDecisionApproachIds?: string[];
|
||||
selectedConflictManagementIds?: string[];
|
||||
```
|
||||
|
||||
The first four are exactly the four **facet groups** in the matrix sheets. The
|
||||
last four are the user's chosen **cards per section**, which the recommendation
|
||||
flow can either pre-select (when picked from a template) or feed back into
|
||||
ranking.
|
||||
|
||||
These same fields are validated server-side by `createFlowStateSchema` in
|
||||
`lib/server/validation/createFlowSchemas.ts` (lines 47–106) — the recommend
|
||||
endpoint should reuse that schema (or a strict subset) instead of redefining
|
||||
the facet shape.
|
||||
|
||||
### 3.3 Wizard step order
|
||||
|
||||
Source of truth is `app/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`). The
|
||||
relevant slice is:
|
||||
|
||||
```
|
||||
review → core-values → communication-methods → membership-methods →
|
||||
decision-approaches → conflict-management → confirm-stakeholders → final-review
|
||||
```
|
||||
|
||||
`docs/create-flow.md`'s step table is **stale**; trust `flowSteps.ts`.
|
||||
|
||||
### 3.4 Where templates already surface in the UI
|
||||
|
||||
| Surface | File |
|
||||
|---|---|
|
||||
| Marketing home "Popular templates" | `app/(marketing)/MarketingRuleStackSection.tsx` |
|
||||
| Templates index | `app/(marketing)/templates/page.tsx` |
|
||||
| Template preview (by slug) | `app/create/review-template/[slug]/page.tsx` |
|
||||
| "Use without changes" → publish | `app/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` |
|
||||
| API list | `app/api/templates/route.ts` (GET only, no params) |
|
||||
|
||||
There is currently **no** recommendation logic, no facet filtering, and the
|
||||
`/create/informational?template=<slug>` query param is a known no-op (see
|
||||
`CreateFlowLayoutClient.tsx` lines 479–482).
|
||||
|
||||
---
|
||||
|
||||
## 4. Repo conventions to follow (don't reinvent)
|
||||
|
||||
These are the patterns the implementation must match. References point at the
|
||||
canonical example for each.
|
||||
|
||||
### 4.1 API routes (`app/api/**/route.ts`)
|
||||
|
||||
`app/api/drafts/me/route.ts` is the reference — every new route in this
|
||||
feature must match this exact shape:
|
||||
|
||||
1. `if (!isDatabaseConfigured()) return dbUnavailable();` — always first.
|
||||
(`lib/server/env.ts`, `lib/server/responses.ts`).
|
||||
2. For auth'd routes: `const user = await getSessionUser();` then
|
||||
`return NextResponse.json({ error: "Unauthorized" }, { status: 401 });` if
|
||||
missing. (Recommendation read endpoints can stay unauthenticated.)
|
||||
3. For request bodies: `readLimitedJson(request)` →
|
||||
`<schema>.safeParse(parsed.value)` → `jsonFromZodError(validated.error)` on
|
||||
failure. (`lib/server/validation/requestBody.ts`,
|
||||
`lib/server/validation/zodHttp.ts`).
|
||||
4. Success: `NextResponse.json({ <key>: data })` — flat object with one or two
|
||||
named keys, no `success: true` envelope.
|
||||
5. Errors: structured `{ error: { code, message } }` (Zod path) or simple
|
||||
`{ error: "..." }` (auth path). Match what's already in the repo.
|
||||
6. Server-side query helpers swallow Prisma failures and return `[]`/`null`
|
||||
(see `listRuleTemplatesFromDb` in `lib/server/ruleTemplates.ts` lines 9–30).
|
||||
Routes do **not** wrap helper calls in `try/catch`.
|
||||
|
||||
### 4.2 Zod schemas live in `lib/server/validation/`
|
||||
|
||||
- One file per feature area (e.g. `createFlowSchemas.ts`, future
|
||||
`templateRecommendationSchemas.ts`).
|
||||
- Export the schema **and** the inferred type
|
||||
(`export type X = z.infer<typeof xSchema>`).
|
||||
- Wrap any free-form JSON blobs with `assertPlainJsonValue` /
|
||||
`DEFAULT_PLAIN_JSON_LIMITS` (`lib/server/validation/plainJson.ts`) so the
|
||||
size/depth bounds match the rest of the API.
|
||||
- Reuse `FLOW_STEP_ORDER` and existing array bounds where they overlap (see
|
||||
the `selectedXxxMethodIds` arrays in `createFlowStateSchema`).
|
||||
|
||||
### 4.3 Prisma access
|
||||
|
||||
- Singleton: `import { prisma } from "lib/server/db";` — never
|
||||
`new PrismaClient()` from app code. (Standalone scripts under `scripts/` /
|
||||
`prisma/` may instantiate their own, matching `prisma/seed.ts` lines
|
||||
363–403.)
|
||||
- Server-only "fetch/list" helpers live under `lib/server/<feature>.ts`,
|
||||
return DTOs (not raw Prisma rows), and degrade gracefully
|
||||
(`isDatabaseConfigured()` short-circuit, `try/catch` → empty result).
|
||||
- No `$transaction` patterns exist yet; **introduce one** for the importer
|
||||
(write `TemplateMethod` + `TemplateMethodFacet` rows atomically).
|
||||
|
||||
### 4.4 DTO style
|
||||
|
||||
- Hand-written `type` aliases that mirror a Prisma `select` clause, co-located
|
||||
with the consumer (see `RuleTemplateDto` in
|
||||
`lib/create/fetchTemplates.ts` lines 5–14).
|
||||
- For a feature with both client and server consumers, put the type in
|
||||
`lib/<feature>/types.ts` and import from both sides.
|
||||
|
||||
### 4.5 Standalone scripts
|
||||
|
||||
- Use `tsx` (already a dev dep; entry point `package.json` `prisma.seed`
|
||||
field).
|
||||
- Layout matches `prisma/seed.ts`: `async function main()`, log a one-line
|
||||
success summary, `console.error(e); process.exit(1)` on failure,
|
||||
`await prisma.$disconnect()` in `finally`.
|
||||
- Add an entry to `package.json` `scripts` (e.g.
|
||||
`"templates:import": "tsx scripts/import-templates-xlsx.ts"`).
|
||||
- No shared dotenv loader — rely on env from the shell / Next runtime.
|
||||
- Support a `--dry-run` flag that validates + diffs without writing.
|
||||
|
||||
### 4.6 Tests
|
||||
|
||||
- Vitest under `tests/unit/*.test.ts` for parsers / validators / pure
|
||||
functions (see `tests/unit/createFlowValidation.test.ts`).
|
||||
- API routes are not unit-tested today; cover route behavior indirectly with a
|
||||
`tests/unit/templateRecommendationSchemas.test.ts` (Zod) plus a fixture
|
||||
workbook + importer test under `tests/unit/importTemplatesXlsx.test.ts`.
|
||||
- E2E for the wizard (if needed) goes under `tests/e2e/*.spec.ts` — not
|
||||
required for CR-88 acceptance.
|
||||
- Test utilities: `tests/utils/test-utils.tsx` (`renderWithProviders`); MSW
|
||||
server in `tests/msw/server.ts`. No Prisma mock helper exists; importer test
|
||||
should use a fixture workbook and stub the `prisma` client at the import
|
||||
site.
|
||||
|
||||
### 4.7 Logging
|
||||
|
||||
- Use `logger` from `lib/logger.ts` for any server-side info/warn/error in
|
||||
scripts and route helpers (matches `app/api/auth/magic-link/request/route.ts`
|
||||
lines 14–15, 35–45). No `apiError` helper exists; do not introduce one.
|
||||
|
||||
### 4.8 New deps
|
||||
|
||||
- `xlsx` (SheetJS) is **not** currently in `package.json`. Add it as a
|
||||
**prod** dep only if the importer is invoked from app code; if the importer
|
||||
is script-only, `devDependency` is fine. CR-88's plan calls for a
|
||||
build/CLI-time importer, so `devDependencies` is the right home.
|
||||
|
||||
### 4.9 i18n / `messages/` constraint
|
||||
|
||||
- Card decks and modal copy are currently keyed in
|
||||
`messages/en/<feature>.json` and read via
|
||||
`useMessages().create.<feature>` (`app/contexts/MessagesContext.tsx`,
|
||||
`messages/en/index.ts`).
|
||||
- Only `en` is wired today, so we **don't** have a translation backlog
|
||||
blocking us. The wiring step (§7) replaces `messages/en/create/{communication,
|
||||
membership,rightRail,conflictManagement}.json` card/modal payloads with
|
||||
values served by `GET /api/template-methods` (still keyed by the same
|
||||
message namespace shape so future i18n can layer on if needed). Header
|
||||
strings, button labels, and other purely-static UI copy stay in
|
||||
`messages/en/*`.
|
||||
|
||||
### 4.10 `.cursorrules` scope
|
||||
|
||||
- The repo's `.cursorrules` PascalCase / lowercase normalization rule applies
|
||||
to **React component props only**. It does **not** apply to API query
|
||||
params, request bodies, or DB columns. The recommendation API uses lowercase
|
||||
facet keys throughout (`orgType`, `scale`, `maturity`, `size`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Authoring contract (informs §6 storage + §7 importer)
|
||||
|
||||
The four spreadsheets together imply this row schema (per matrix workbook):
|
||||
|
||||
```ts
|
||||
type MatrixRow = {
|
||||
/** Stable slug derived from `Label`/`Title` (kebab-case, lowercase, ascii).
|
||||
* Used as the card id everywhere downstream. */
|
||||
id: string;
|
||||
|
||||
/** Section this row belongs to. One of: communication, membership,
|
||||
* decisionMaking, conflictManagement. (values is not yet sheet-driven.) */
|
||||
section: "communication" | "membership" | "decisionMaking" | "conflictManagement";
|
||||
|
||||
/** Card-facing copy. Keys differ per section; importer normalizes. */
|
||||
card: {
|
||||
label: string;
|
||||
description: string;
|
||||
/** Section-specific long-form fields (3–4 per section). */
|
||||
modalSections: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Optional numeric scalar fields (e.g. decisionMaking `Consensus Level`). */
|
||||
scalars?: Record<string, number>;
|
||||
|
||||
/** Facet matches (✓ → true, x/blank → false). Keys are canonical facet ids. */
|
||||
facets: {
|
||||
size: Record<"1" | "2_5" | "6_12" | "13_100" | "100_100k", boolean>;
|
||||
orgType: Record<"dao" | "forProfit" | "nonprofit" | "openSource" | "mutualAid" | "workersCoop", boolean>;
|
||||
scale: Record<"global" | "national" | "regional" | "local", boolean>;
|
||||
maturity: Record<"earlyStage" | "growthStage" | "established" | "enterprise", boolean>;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
A sibling **manifest** documents the per-section section-key mapping and
|
||||
column header → canonical facet/scalar key mapping, so the importer can be
|
||||
stable across header rewording.
|
||||
|
||||
---
|
||||
|
||||
## 6. Storage (decided: normalized tables)
|
||||
|
||||
We are introducing two new Prisma models. Hand-typed `COMPOSITION_BY_SLUG` in
|
||||
`prisma/seed.ts` is replaced by template rows that **reference** method slugs.
|
||||
|
||||
```prisma
|
||||
model TemplateMethod {
|
||||
id String @id @default(cuid())
|
||||
section String // communication | membership | decisionMaking | conflictManagement
|
||||
slug String
|
||||
label String
|
||||
description String
|
||||
modalSections Json // { corePrinciple: "...", logisticsAdmin: "...", ... }
|
||||
scalars Json? // { consensusLevel: 0.51 }
|
||||
sortOrder Int @default(0)
|
||||
facets TemplateMethodFacet[]
|
||||
@@unique([section, slug])
|
||||
@@index([section])
|
||||
}
|
||||
|
||||
model TemplateMethodFacet {
|
||||
id String @id @default(cuid())
|
||||
methodId String
|
||||
group String // size | orgType | scale | maturity
|
||||
value String // e.g. "workersCoop"
|
||||
matches Boolean // ✓ → true, x/blank → false
|
||||
weight Float? // optional numeric override for future scoring
|
||||
method TemplateMethod @relation(fields: [methodId], references: [id], onDelete: Cascade)
|
||||
@@unique([methodId, group, value])
|
||||
@@index([group, value, matches])
|
||||
}
|
||||
```
|
||||
|
||||
`RuleTemplate.body` continues to express a **chosen composition** of methods
|
||||
(one or more per section). Curated templates in `prisma/seed.ts` become
|
||||
references to `TemplateMethod.slug` instead of literal copy strings — when
|
||||
copy changes in the spreadsheet, every template that references that slug
|
||||
inherits the new copy.
|
||||
|
||||
A follow-up (out of scope for CR-88) may add a `RuleTemplateMethodLink` join
|
||||
table if templates need ordering or per-template overrides; the current `body`
|
||||
JSON shape is sufficient for the first ship.
|
||||
|
||||
---
|
||||
|
||||
## 7. Importer (`scripts/import-templates-xlsx.ts`)
|
||||
|
||||
Phased plan that the implementation agent can follow top-to-bottom. Mirrors
|
||||
the structure of `prisma/seed.ts` (singleton client, `main()` +
|
||||
`finally { $disconnect }`, `process.exit(1)` on failure).
|
||||
|
||||
1. **Read `.xlsx`** with [`xlsx`](https://www.npmjs.com/package/xlsx) (SheetJS,
|
||||
add as devDependency) from a configurable input dir (default
|
||||
`data/template-matrix/`). The four workbooks live there as committed
|
||||
artifacts, not in `Downloads/`.
|
||||
2. **Schema-validate per section** with Zod schemas that live in
|
||||
`lib/server/validation/templateRecommendationSchemas.ts` so the API and
|
||||
importer share the row shape: required column headers, allowed cell
|
||||
symbols (`✓`, `x`, blank, decimal for `Consensus Level`).
|
||||
3. **Normalize**: kebab-case slug from label, strip
|
||||
`Organization Type:` / `Location:` / `Organizational Maturity:` prefixes,
|
||||
collapse whitespace, normalize curly quotes.
|
||||
4. **Cross-sheet validation**: facet columns must match the canonical 19-column
|
||||
set; unknown columns fail loudly via the importer (use `logger.error`).
|
||||
5. **Diff & upsert** inside `prisma.$transaction([...])`: upsert
|
||||
`TemplateMethod` rows by `(section, slug)`; delete + recreate
|
||||
`TemplateMethodFacet` rows for each method.
|
||||
6. **Emit a JSON snapshot** to `prisma/data/template-matrix.json` so
|
||||
`prisma/seed.ts` can replay imports when the source workbooks aren't
|
||||
available (e.g. CI seed without the spreadsheet checked in).
|
||||
7. **Flags**: `--dry-run` (validate + diff, no writes), `--allow-warnings`
|
||||
(don't fail on the row-32 / row-11 issues in §2.4 while authors are
|
||||
iterating).
|
||||
8. **Tests** in `tests/unit/importTemplatesXlsx.test.ts`: a fixture workbook
|
||||
with two rows per section asserts both validation errors (unknown column,
|
||||
bad symbol, miscategorized cell) and successful normalization. Reuse
|
||||
Vitest patterns from `tests/unit/createFlowValidation.test.ts`.
|
||||
|
||||
Per Ticket 16 and the roadmap, **prefer batch `.xlsx` import** over a live
|
||||
Google Sheets API in production. Authors export to `.xlsx` and a maintainer
|
||||
runs `npm run templates:import` (or CI does on a `data/template-matrix/` change).
|
||||
|
||||
---
|
||||
|
||||
## 8. APIs
|
||||
|
||||
Two read endpoints. Both follow §4.1 conventions exactly: `dbUnavailable()`
|
||||
guard → server helper from `lib/server/templateMethods.ts` →
|
||||
`NextResponse.json({ ... })`.
|
||||
|
||||
### 8.1 `GET /api/templates` (rewrite)
|
||||
|
||||
Query params (all optional):
|
||||
|
||||
- `facet.size=<chipId>` (repeatable)
|
||||
- `facet.orgType=<chipId>` (repeatable)
|
||||
- `facet.scale=<chipId>` (repeatable)
|
||||
- `facet.maturity=<chipId>` (repeatable)
|
||||
|
||||
Behavior:
|
||||
|
||||
- No params → existing curated ordering (`featured`, `sortOrder`, `title`),
|
||||
no scoring.
|
||||
- With facets → score each template by counting matching facets across the
|
||||
methods referenced in its `body`; return ranked `templates` plus an
|
||||
optional `scores` map.
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
templates: RuleTemplateDto[],
|
||||
scores?: Record<string, { score: number; matchedFacets: string[] }>
|
||||
}
|
||||
```
|
||||
|
||||
Param parsing helper lives next to `listRuleTemplatesFromDb` in
|
||||
`lib/server/ruleTemplates.ts` (e.g. `parseTemplateFacetsFromSearchParams`).
|
||||
|
||||
### 8.2 `GET /api/template-methods?section=<section>[&facet.*=...]`
|
||||
|
||||
Powers the four card-deck wizard steps and the section-level recommendation
|
||||
view. Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
section: "communication" | "membership" | "decisionMaking" | "conflictManagement",
|
||||
methods: Array<{
|
||||
slug: string;
|
||||
label: string;
|
||||
description: string;
|
||||
modalSections: Record<string, string>;
|
||||
scalars?: Record<string, number>;
|
||||
/** Per-method facet match against the requested facets (omitted when no facets passed). */
|
||||
matches?: { score: number; matchedFacets: string[] };
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Server helper: `listTemplateMethodsFromDb({ section, facets })` in
|
||||
`lib/server/templateMethods.ts`. Same swallow-and-return-`[]` failure mode as
|
||||
`listRuleTemplatesFromDb`.
|
||||
|
||||
### 8.3 `POST /api/templates/recommend` (follow-up, optional)
|
||||
|
||||
If product wants to send the full `CreateFlowState` (not just facet ids), the
|
||||
body schema **reuses** `createFlowStateSchema` from
|
||||
`lib/server/validation/createFlowSchemas.ts`. Same scoring engine, just a
|
||||
richer input. Skip until §8.1 + §8.2 ship.
|
||||
|
||||
**Empty / partial facets:** never error. Fall back to today's ordering and
|
||||
return all rows.
|
||||
|
||||
---
|
||||
|
||||
## 9. Wizard wiring (UI follow-on, not strictly part of CR-88)
|
||||
|
||||
Once the API exists:
|
||||
|
||||
- `communication-methods` / `membership-methods` / `decision-approaches` /
|
||||
`conflict-management` screens each call
|
||||
`GET /api/template-methods?section=...&facet.*=...`. The card label and
|
||||
modal copy come from the API response, not from
|
||||
`messages/en/create/<section>.json`. Static JSON in those four files is
|
||||
pruned to the page-level strings (header titles, button labels, modal
|
||||
chrome) only.
|
||||
- Selecting a template on the marketing home or `templates/` page can prefill
|
||||
the create flow's `selected*MethodIds` from the template's composition (this
|
||||
closes the `?template=` no-op gap noted in
|
||||
`CreateFlowLayoutClient.tsx`).
|
||||
- Recommendations should never **hide** options from the user — ranking only.
|
||||
Authors expect to see "all 32 decision-making patterns" with the ✓-matching
|
||||
ones surfaced first.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for product before coding
|
||||
|
||||
1. **Should `Values` also be sheet-driven?** Today it's free-text only and
|
||||
not in any of the four matrices. Roadmap implies eventual parity.
|
||||
2. **Scoring vs filtering**: do we want to **hide** non-✓ rows when a facet
|
||||
is set, or only **rank** them? Recommend ranking with a soft cutoff.
|
||||
3. **Per-template featured composition vs library-wide**: should
|
||||
`RuleTemplate` rows continue to exist as named compositions
|
||||
("Consensus", "Elected Board", etc.), or become derived from a
|
||||
"this is the best mix for nonprofit + 13–100 + early stage" scoring? Doc
|
||||
today assumes the former — templates remain curated.
|
||||
4. **Authoring source of truth**: are the `Downloads/*.xlsx` files committed
|
||||
to `data/template-matrix/` going forward, or do they live in a Drive folder
|
||||
pulled by the importer at build time? Recommend committing.
|
||||
5. **Data validation strictness**: the current Decision-making sheet has a
|
||||
miscategorized cell (row 32, see §2.4). Importer should fail by default,
|
||||
with a `--allow-warnings` flag for in-progress edits.
|
||||
|
||||
---
|
||||
|
||||
## 11. Test plan (acceptance for CR-88)
|
||||
|
||||
- [ ] `scripts/import-templates-xlsx.ts` runs end-to-end on the four committed
|
||||
workbooks with no errors and produces the expected DB diff (or JSON
|
||||
snapshot).
|
||||
- [ ] Editing a row in the source workbook and re-running the importer changes
|
||||
the rank order returned by `GET /api/templates?facet.orgType=4`
|
||||
(the `Nonprofit` chip id) without any manual Studio edit.
|
||||
- [ ] `tests/unit/importTemplatesXlsx.test.ts` rejects each documented
|
||||
validation failure (unknown column, bad symbol, miscategorized row).
|
||||
- [ ] `tests/unit/templateRecommendationSchemas.test.ts` exercises the Zod
|
||||
schemas the importer and API share.
|
||||
- [ ] Manual smoke on the four wizard card-deck steps: facet-narrowed
|
||||
ordering surfaces matching cards first; facetless GET returns the
|
||||
full curated list.
|
||||
- [ ] No regression in existing template surfaces (marketing home, templates
|
||||
index, review-template preview).
|
||||
|
||||
---
|
||||
|
||||
## 12. Source files referenced
|
||||
|
||||
- `prisma/schema.prisma` — `RuleTemplate` model (lines 64–73).
|
||||
- `prisma/seed.ts` — current curated composition + xlsx-shaped helpers
|
||||
(lines 1–404).
|
||||
- `app/api/templates/route.ts` — existing GET endpoint (to be rewritten).
|
||||
- `app/api/drafts/me/route.ts` — reference route shape (`dbUnavailable` →
|
||||
`getSessionUser` → `readLimitedJson` → `safeParse` → `jsonFromZodError`).
|
||||
- `lib/server/db.ts` — Prisma singleton (lines 1–18).
|
||||
- `lib/server/responses.ts` — `dbUnavailable()` (lines 1–8).
|
||||
- `lib/server/ruleTemplates.ts` — `listRuleTemplatesFromDb` (lines 9–30).
|
||||
- `lib/server/validation/createFlowSchemas.ts` — schema to reuse for
|
||||
`POST /api/templates/recommend` (lines 47–106).
|
||||
- `lib/server/validation/requestBody.ts` — `readLimitedJson` (lines 13–48).
|
||||
- `lib/server/validation/zodHttp.ts` — `jsonFromZodError` (lines 4–17).
|
||||
- `lib/server/validation/plainJson.ts` — `assertPlainJsonValue` /
|
||||
`DEFAULT_PLAIN_JSON_LIMITS`.
|
||||
- `lib/logger.ts` — server-side `logger`.
|
||||
- `app/create/types.ts` — `CreateFlowState` and facet fields.
|
||||
- `app/create/utils/flowSteps.ts` — canonical step order.
|
||||
- `app/create/utils/createFlowScreenRegistry.ts` — screen layout per step.
|
||||
- `app/create/screens/select/CommunityStructureSelectScreen.tsx` — chip-id
|
||||
derivation pattern (positional `String(i+1)`).
|
||||
- `app/create/screens/card/CommunicationMethodsScreen.tsx` — section-field
|
||||
contract (`SECTION_FIELDS`).
|
||||
- `messages/en/create/{communitySize,communityStructure,communication,membership,rightRail,conflictManagement}.json` —
|
||||
current static card / chip copy that the matrix supersedes.
|
||||
- `lib/templates/governanceTemplateCatalog.ts`,
|
||||
`lib/templates/templateGridPresentation.ts`,
|
||||
`lib/create/fetchTemplates.ts` — current presentation/DTO layer.
|
||||
- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for new
|
||||
schema/importer tests.
|
||||
- Roadmap: `docs/backend-roadmap.md` §4 (lines 83–85), §13.
|
||||
- Spec: `docs/backend-linear-tickets.md` Ticket 16 (lines 280–304).
|
||||
|
||||
## 13. Source workbooks
|
||||
|
||||
| File | Sheet | Rows | Cols | Section |
|
||||
|---|---|---|---|---|
|
||||
| `Communication Methods.xlsx` | `Current` | 11 cards | 24 | `communication` |
|
||||
| `Group_Membership_Methods.xlsx` | `Current` | 19 cards | 24 | `membership` |
|
||||
| `Decision-making.xlsx` | `Current` | 32 cards | 26 | `decisionMaking` |
|
||||
| `Conflict Management Methods.xlsx` | `Current` | 19 cards | 25 | `conflictManagement` |
|
||||
|
||||
Counts include the header row. Decision-making has 26 columns because of two
|
||||
extra content fields (`Consensus Level`, `Step-by-Step Instructions` vs the
|
||||
4-section pattern of the others).
|
||||
@@ -79,6 +79,42 @@
|
||||
"logisticsAdmin": "The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the \"Raise Hand\" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.",
|
||||
"codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. \"Zoom-bombing\" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk."
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"title": "Label",
|
||||
"description": "Collaborative work to reach a resolution that all parties can agree upon.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"logisticsAdmin": "",
|
||||
"codeOfConduct": ""
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"title": "Label",
|
||||
"description": "Structured sessions where parties collaboratively resolve disputes.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"logisticsAdmin": "",
|
||||
"codeOfConduct": ""
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"title": "Label",
|
||||
"description": "Members vote to resolve a dispute democratically.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"logisticsAdmin": "",
|
||||
"codeOfConduct": ""
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"title": "Label",
|
||||
"description": "Invite-only",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"logisticsAdmin": "",
|
||||
"codeOfConduct": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
"description": "Confirm to select this option.",
|
||||
"nextButtonText": "Confirm"
|
||||
},
|
||||
"addApproach": {
|
||||
"nextButtonText": "Add Approach"
|
||||
},
|
||||
"sectionHeadings": {
|
||||
"corePrinciple": "Core Principle",
|
||||
"applicableScope": "Applicable Scope",
|
||||
"processProtocol": "Process Protocol",
|
||||
"restorationFallbacks": "Restoration & Fallbacks"
|
||||
},
|
||||
"scopeAddButtonLabel": "Add Applicable Scope",
|
||||
"cards": {
|
||||
"peer-mediation": {
|
||||
"label": "Peer Mediation",
|
||||
@@ -51,26 +61,83 @@
|
||||
"modals": {
|
||||
"peer-mediation": {
|
||||
"title": "Peer Mediation",
|
||||
"description": "Trained members within the organization mediate disputes among peers."
|
||||
"description": "Trained members within the organization mediate disputes among peers.",
|
||||
"sections": {
|
||||
"corePrinciple": "We democratize conflict skills. Instead of relying on professional outsiders, trained peers help their colleagues resolve disputes, reinforcing the idea that we take care of each other.",
|
||||
"applicableScope": ["Low-level friction", "Misunderstandings"],
|
||||
"processProtocol": "A volunteer peer (who is not a manager) invites the disputants to a private chat. Using a simple script, they ask questions like 'Tell us your side,' 'Tell us what you need,' and 'What can you agree to?'. The peer keeps the conversation focused on future interactions rather than past grievances. The disputants retain full control over the resolution.",
|
||||
"restorationFallbacks": "The goal is a verbal agreement to try a new way of interacting. If the peer mediator determines the issue is too complex or involves serious harassment, they are required to refer the case to professional Mediation or a Judicial Committee."
|
||||
}
|
||||
},
|
||||
"conflict-resolution-council": {
|
||||
"title": "Conflict Resolution Council",
|
||||
"description": "Senior members with institutional knowledge provide guidance or decisions."
|
||||
"description": "Senior members with institutional knowledge provide guidance or decisions.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"facilitated-negotiation": {
|
||||
"title": "Facilitated Negotiation",
|
||||
"description": "A neutral facilitator helps guide the negotiation process."
|
||||
"description": "A neutral facilitator helps guide the negotiation process.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"ad-hoc-arbitration": {
|
||||
"title": "Ad Hoc Arbitration",
|
||||
"description": "Arbitrators are chosen specifically for a particular case."
|
||||
"description": "Arbitrators are chosen specifically for a particular case.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"conflict-workshops": {
|
||||
"title": "Conflict Workshops",
|
||||
"description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
|
||||
"description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"6": { "title": "Label", "description": "Additional conflict management approach." },
|
||||
"7": { "title": "Label", "description": "Additional conflict management approach." },
|
||||
"8": { "title": "Label", "description": "Additional conflict management approach." }
|
||||
"6": {
|
||||
"title": "Label",
|
||||
"description": "Additional conflict management approach.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"title": "Label",
|
||||
"description": "Additional conflict management approach.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"title": "Label",
|
||||
"description": "Additional conflict management approach.",
|
||||
"sections": {
|
||||
"corePrinciple": "",
|
||||
"applicableScope": [],
|
||||
"processProtocol": "",
|
||||
"restorationFallbacks": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
"description": "Confirm to select this option.",
|
||||
"nextButtonText": "Confirm"
|
||||
},
|
||||
"addPlatform": {
|
||||
"nextButtonText": "Add Platform"
|
||||
},
|
||||
"sectionHeadings": {
|
||||
"eligibility": "Eligibility & Philosophy",
|
||||
"joiningProcess": "Joining Process",
|
||||
"expectations": "Expectations & Removal"
|
||||
},
|
||||
"cards": {
|
||||
"open-access": {
|
||||
"label": "Open Access",
|
||||
@@ -51,26 +59,75 @@
|
||||
"modals": {
|
||||
"open-access": {
|
||||
"title": "Open Access",
|
||||
"description": "Maximum inclusion. Anyone can join immediately by simply showing up."
|
||||
"description": "Maximum inclusion. Anyone can join immediately by simply showing up.",
|
||||
"sections": {
|
||||
"eligibility": "Membership is open to any individual who lives in [Region/Context] and supports our mission. We believe that gatekeeping creates unnecessary hierarchy, so we prioritize radical inclusivity and low barriers to entry. Our only requirement is a commitment to mutual respect and adherence to the Community Code of Conduct.",
|
||||
"joiningProcess": "There is no formal application or waiting period. A person becomes a member instantly by joining our [Digital Platform/Meeting], introducing themselves to the group, and explicitly agreeing to the community standards. Access to all channels and working groups is granted immediately upon sign-up.",
|
||||
"expectations": "Members are expected to participate constructively. We view removal as a last resort. Barring immediate safety threats, no member will be banned without first going through the steps outlined in our Conflict Management Policy. We prioritize the restorative measures defined in that section over punitive expulsion. However, refusal to engage in that process may result in removal."
|
||||
}
|
||||
},
|
||||
"orientation-required": {
|
||||
"title": "Orientation Required",
|
||||
"description": "Newcomers must attend a training or orientation session."
|
||||
"description": "Newcomers must attend a training or orientation session.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"invitation-only": {
|
||||
"title": "Invitation Only",
|
||||
"description": "New members can only join if they are 'vouched for' by existing members."
|
||||
"description": "New members can only join if they are 'vouched for' by existing members.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"contribution-based": {
|
||||
"title": "Contribution Based",
|
||||
"description": "Membership is reserved for people contributing their labor."
|
||||
"description": "Membership is reserved for people contributing their labor.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"mentorship": {
|
||||
"title": "Mentorship",
|
||||
"description": "New members are paired with 'Mentors' to guide them through a probationary period."
|
||||
"description": "New members are paired with 'Mentors' to guide them through a probationary period.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"6": { "title": "Label", "description": "Additional membership approach." },
|
||||
"7": { "title": "Label", "description": "Additional membership approach." },
|
||||
"8": { "title": "Label", "description": "Additional membership approach." }
|
||||
"6": {
|
||||
"title": "Label",
|
||||
"description": "Additional membership approach.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"title": "Label",
|
||||
"description": "Additional membership approach.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"title": "Label",
|
||||
"description": "Additional membership approach.",
|
||||
"sections": {
|
||||
"eligibility": "",
|
||||
"joiningProcess": "",
|
||||
"expectations": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,39 @@
|
||||
"toggleSeeAll": "See all decision approaches",
|
||||
"toggleShowLess": "Show less"
|
||||
},
|
||||
"confirmModal": {
|
||||
"title": "Confirm selection",
|
||||
"description": "Confirm to select this option.",
|
||||
"nextButtonText": "Confirm"
|
||||
},
|
||||
"addApproach": {
|
||||
"nextButtonText": "Add Approach"
|
||||
},
|
||||
"sectionHeadings": {
|
||||
"corePrinciple": "Core Principle",
|
||||
"applicableScope": "Applicable Scope",
|
||||
"stepByStepInstructions": "Step-by-Step Instructions",
|
||||
"consensusLevel": "Consensus Level",
|
||||
"objectionsDeadlocks": "Objections & Deadlocks"
|
||||
},
|
||||
"scopeAddButtonLabel": "Add Applicable Scope",
|
||||
"modals": {
|
||||
"lazy-consensus": {
|
||||
"title": "Lazy Consensus",
|
||||
"description": "A decision is assumed approved unless objections are raised within a specified timeframe.",
|
||||
"sections": {
|
||||
"corePrinciple": "We prioritize momentum and trust over bureaucracy. By assuming good faith, we avoid bottlenecks; silence is interpreted as consent to keep the work moving.",
|
||||
"applicableScope": [
|
||||
"Daily Operations",
|
||||
"Minor Expenditures",
|
||||
"Working Group Decisions"
|
||||
],
|
||||
"stepByStepInstructions": "Post your proposal to the relevant channel with a specific deadline, such as 'Merging in 72 hours.' If the deadline passes without any objections, you are authorized to proceed. Explicit support is welcome but not required.",
|
||||
"consensusLevel": 90,
|
||||
"objectionsDeadlocks": "Any member can pause the process by raising a 'Block' or 'Concern' before the deadline. The proposer is then required to pause execution and engage in a dialogue to resolve the concern. If the disagreement cannot be resolved asynchronously, the proposal is escalated to a synchronous meeting or a higher governance tier for a final decision."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"id": "lazy-consensus",
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen";
|
||||
import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
|
||||
|
||||
export default {
|
||||
title: "Pages/Create Flow/Right rail",
|
||||
component: RightRailScreen,
|
||||
title: "Pages/Create Flow/Decision approaches",
|
||||
component: DecisionApproachesScreen,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
} from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, test, expect, afterEach } from "vitest";
|
||||
import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen";
|
||||
import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Create flow right-rail page", () => {
|
||||
describe("Create flow decision-approaches page", () => {
|
||||
test("renders without error", () => {
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
@@ -24,7 +24,7 @@ describe("Create flow right-rail page", () => {
|
||||
});
|
||||
|
||||
test("renders sidebar description with add link", () => {
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const description = screen.getByText((content, element) => {
|
||||
if (element?.tagName !== "P") return false;
|
||||
@@ -39,7 +39,7 @@ describe("Create flow right-rail page", () => {
|
||||
});
|
||||
|
||||
test("renders message box with title and checkboxes", () => {
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const region = screen.getByRole("region", {
|
||||
name: "Consider defining approaches to steward key resources:",
|
||||
@@ -65,7 +65,7 @@ describe("Create flow right-rail page", () => {
|
||||
});
|
||||
|
||||
test("renders card stack with See all decision approaches toggle", () => {
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "See all decision approaches" }),
|
||||
@@ -73,7 +73,7 @@ describe("Create flow right-rail page", () => {
|
||||
});
|
||||
|
||||
test("renders recommended approach cards", () => {
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
@@ -94,7 +94,7 @@ describe("Create flow right-rail page", () => {
|
||||
|
||||
test("toggle expands and shows Show less", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: "See all decision approaches",
|
||||
@@ -108,7 +108,7 @@ describe("Create flow right-rail page", () => {
|
||||
|
||||
test("expanded view shows Label cards", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: "See all decision approaches",
|
||||
@@ -119,21 +119,29 @@ describe("Create flow right-rail page", () => {
|
||||
expect(labelButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking a card toggles selection", async () => {
|
||||
test("clicking a card opens the create modal and confirming selects it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const card = screen.getByRole("button", {
|
||||
name: /Lazy Consensus: A decision is assumed approved/,
|
||||
});
|
||||
await user.click(card);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmButton = within(dialog).getByRole("button", {
|
||||
name: "Add Approach",
|
||||
});
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("SELECTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("message box checkboxes are interactive", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RightRailScreen />);
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
const amendCheckbox = screen.getByRole("checkbox", {
|
||||
name: "Amend your CommunityRule",
|
||||
Reference in New Issue
Block a user