QA pass: layout, create-flow, and About books #68

Merged
an.di merged 38 commits from adilallo/fix/CR-129-create-rule-button into main 2026-08-26 15:46:56 +00:00
19 changed files with 567 additions and 56 deletions
Showing only changes of commit b1ca1a748e - Show all commits
@@ -84,6 +84,7 @@ function ApplicableScopeFieldComponent({
disabled={readOnly} disabled={readOnly}
onClick={() => !readOnly && onToggleScope(scope)} onClick={() => !readOnly && onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`} ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
className="max-w-full"
/> />
); );
})} })}
@@ -35,6 +35,10 @@ import {
decisionApproachPresetFor, decisionApproachPresetFor,
membershipPresetFor, membershipPresetFor,
} from "../../../../lib/create/finalReviewChipPresets"; } from "../../../../lib/create/finalReviewChipPresets";
import {
applyDecisionApproachKeyResources,
selectedKeyResourceLabelsFromCheckedIds,
} from "../../../../lib/create/decisionApproachKeyResources";
import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId"; import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId";
import { usesWizardFieldBlocksModalBody } from "../../../../lib/create/usesWizardFieldBlocksModalBody"; import { usesWizardFieldBlocksModalBody } from "../../../../lib/create/usesWizardFieldBlocksModalBody";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
@@ -1266,6 +1270,7 @@ function facetSeedSignature(
return JSON.stringify({ return JSON.stringify({
meta: state.customMethodCardMetaById?.[id] ?? null, meta: state.customMethodCardMetaById?.[id] ?? null,
details: state.decisionApproachDetailsById?.[id] ?? null, details: state.decisionApproachDetailsById?.[id] ?? null,
keyResources: state.selectedDecisionKeyResourceIds ?? null,
blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, blocks: state.customMethodCardFieldBlocksById?.[id] ?? null,
}); });
case "conflictManagement": case "conflictManagement":
@@ -1330,12 +1335,18 @@ function seedDraftForTarget(
const saved = const saved =
state.decisionApproachDetailsById?.[target.overrideKey] ?? state.decisionApproachDetailsById?.[target.overrideKey] ??
decisionApproachPresetFor(target.overrideKey); decisionApproachPresetFor(target.overrideKey);
const withKeys = applyDecisionApproachKeyResources(
saved,
selectedKeyResourceLabelsFromCheckedIds(
state.selectedDecisionKeyResourceIds ?? [],
),
);
return { return {
groupKey: "decisionApproaches", groupKey: "decisionApproaches",
value: { value: {
...saved, ...withKeys,
applicableScope: [...saved.applicableScope], applicableScope: [...withKeys.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope], selectedApplicableScope: [...withKeys.selectedApplicableScope],
}, },
}; };
} }
@@ -7,12 +7,13 @@
* `markCreateFlowInteraction` live in the parent. * `markCreateFlowInteraction` live in the parent.
*/ */
import { memo, useCallback } from "react"; import { memo, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField"; import ModalTextAreaField from "../ModalTextAreaField";
import ApplicableScopeField from "../ApplicableScopeField"; import ApplicableScopeField from "../ApplicableScopeField";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock"; import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import type { DecisionApproachDetailEntry } from "../../types"; import type { DecisionApproachDetailEntry } from "../../types";
import { withDecisionApproachKeyResourceScopes } from "../../../../../lib/create/decisionApproachKeyResources";
export interface DecisionApproachEditFieldsProps { export interface DecisionApproachEditFieldsProps {
value: DecisionApproachDetailEntry; value: DecisionApproachDetailEntry;
@@ -32,6 +33,11 @@ function DecisionApproachEditFieldsComponent({
const m = useMessages(); const m = useMessages();
const t = m.create.customRule.decisionApproaches; const t = m.create.customRule.decisionApproaches;
const scopes = useMemo(
() => withDecisionApproachKeyResourceScopes(value.applicableScope),
[value.applicableScope],
);
const patch = useCallback( const patch = useCallback(
<K extends keyof DecisionApproachDetailEntry>( <K extends keyof DecisionApproachDetailEntry>(
key: K, key: K,
@@ -53,7 +59,7 @@ function DecisionApproachEditFieldsComponent({
<ApplicableScopeField <ApplicableScopeField
label={t.sectionHeadings.applicableScope} label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel} addLabel={t.scopeAddButtonLabel}
scopes={value.applicableScope} scopes={scopes}
selectedScopes={value.selectedApplicableScope} selectedScopes={value.selectedApplicableScope}
readOnly={readOnly} readOnly={readOnly}
onToggleScope={(scope) => onToggleScope={(scope) =>
@@ -33,6 +33,13 @@ import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets"; import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceIdsFromLabels,
selectedKeyResourceLabelsFromCheckedIds,
stringArraysEqual,
syncDecisionApproachKeyResourceDetails,
} from "../../../../../lib/create/decisionApproachKeyResources";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom"; import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder"; import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
@@ -71,9 +78,6 @@ export function DecisionApproachesScreen() {
const customizeSnapshotRef = useRef< const customizeSnapshotRef = useRef<
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
>(null); >(null);
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null); const [pendingCardId, setPendingCardId] = useState<string | null>(null);
@@ -90,6 +94,11 @@ export function DecisionApproachesScreen() {
>(null); >(null);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
const messageBoxCheckedIds = state.selectedDecisionKeyResourceIds ?? [];
const selectedKeyResourceLabels = useMemo(
() => selectedKeyResourceLabelsFromCheckedIds(messageBoxCheckedIds),
[messageBoxCheckedIds],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo( const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() => () =>
@@ -137,26 +146,52 @@ export function DecisionApproachesScreen() {
const handleMessageBoxCheckboxChange = useCallback( const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => { (id: string, checked: boolean) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) => const nextCheckedIds = checked
checked ? [...prev, id] : prev.filter((x) => x !== id), ? [...messageBoxCheckedIds, id]
); : messageBoxCheckedIds.filter((x) => x !== id);
const nextLabels =
selectedKeyResourceLabelsFromCheckedIds(nextCheckedIds);
if (pendingDraft) {
setPendingDraft(
applyDecisionApproachKeyResources(pendingDraft, nextLabels),
);
}
updateState({
selectedDecisionKeyResourceIds: nextCheckedIds,
decisionApproachDetailsById: syncDecisionApproachKeyResourceDetails(
state.decisionApproachDetailsById,
selectedIds,
nextLabels,
decisionApproachPresetFor,
),
});
}, },
[markCreateFlowInteraction], [
markCreateFlowInteraction,
messageBoxCheckedIds,
pendingDraft,
selectedIds,
state.decisionApproachDetailsById,
updateState,
],
); );
const seedDraft = useCallback( const seedDraft = useCallback(
(id: string): DecisionApproachDetailEntry => { (id: string): DecisionApproachDetailEntry => {
const saved = state.decisionApproachDetailsById?.[id]; const saved = state.decisionApproachDetailsById?.[id];
if (saved) { const base = saved
return { ? {
...saved, ...saved,
applicableScope: [...saved.applicableScope], applicableScope: [...saved.applicableScope],
selectedApplicableScope: [...saved.selectedApplicableScope], selectedApplicableScope: [...saved.selectedApplicableScope],
}; }
} : decisionApproachPresetFor(id);
return decisionApproachPresetFor(id); return applyDecisionApproachKeyResources(
base,
selectedKeyResourceLabels,
);
}, },
[state.decisionApproachDetailsById], [selectedKeyResourceLabels, state.decisionApproachDetailsById],
); );
const handleCardSelect = useCallback( const handleCardSelect = useCallback(
@@ -202,8 +237,31 @@ export function DecisionApproachesScreen() {
(next: DecisionApproachDetailEntry) => { (next: DecisionApproachDetailEntry) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setPendingDraft(next); setPendingDraft(next);
const nextCheckedIds = decisionApproachKeyResourceIdsFromLabels(
next.selectedApplicableScope,
);
if (stringArraysEqual(nextCheckedIds, messageBoxCheckedIds)) {
return;
}
const nextLabels =
selectedKeyResourceLabelsFromCheckedIds(nextCheckedIds);
updateState({
selectedDecisionKeyResourceIds: nextCheckedIds,
decisionApproachDetailsById: syncDecisionApproachKeyResourceDetails(
state.decisionApproachDetailsById,
selectedIds,
nextLabels,
decisionApproachPresetFor,
),
});
}, },
[markCreateFlowInteraction], [
markCreateFlowInteraction,
messageBoxCheckedIds,
selectedIds,
state.decisionApproachDetailsById,
updateState,
],
); );
const isSelectedCardModal = const isSelectedCardModal =
@@ -594,7 +652,10 @@ export function DecisionApproachesScreen() {
}, },
decisionApproachDetailsById: { decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}), ...(state.decisionApproachDetailsById ?? {}),
[id]: decisionApproachPresetFor(id), [id]: applyDecisionApproachKeyResources(
decisionApproachPresetFor(id),
selectedKeyResourceLabels,
),
}, },
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
@@ -606,6 +667,7 @@ export function DecisionApproachesScreen() {
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
selectedKeyResourceLabels,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
state.decisionApproachDetailsById, state.decisionApproachDetailsById,
+6
View File
@@ -165,6 +165,12 @@ export interface CreateFlowState {
>; >;
membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>; membershipMethodDetailsById?: Record<string, MembershipMethodDetailEntry>;
decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>; decisionApproachDetailsById?: Record<string, DecisionApproachDetailEntry>;
/**
* Checked “key resource” ids from the decision-approaches InfoMessageBox
* (`amend`, `finances`, `project`, `discipline`). Selecting one also selects
* the matching Applicable Scope chip on every decision approach.
*/
selectedDecisionKeyResourceIds?: string[];
conflictManagementDetailsById?: Record< conflictManagementDetailsById?: Record<
string, string,
ConflictManagementDetailEntry ConflictManagementDetailEntry
@@ -84,14 +84,14 @@ export function SelectionView({
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<div className="flex min-w-0 flex-1 flex-col gap-1"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="text-medium-label text-[var(--color-content-invert-secondary)]"> <span className="min-w-0 break-words text-medium-label text-[var(--color-content-invert-secondary)]">
{label} {label}
</span> </span>
{showInfoIcon ? <InfoIcon /> : null} {showInfoIcon ? <InfoIcon /> : null}
</div> </div>
{supportText ? ( {supportText ? (
<p className="text-x-small-paragraph text-[var(--color-content-invert-tertiary)]"> <p className="min-w-0 break-words text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
{supportText} {supportText}
</p> </p>
) : null} ) : null}
@@ -17,7 +17,7 @@ export function CheckboxView({
}: CheckboxViewProps) { }: CheckboxViewProps) {
return ( return (
<label <label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${ className={`inline-flex max-w-full min-w-0 items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : "" disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`} } ${className}`}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
@@ -51,7 +51,7 @@ export function CheckboxView({
{label && ( {label && (
<span <span
id={labelId} id={labelId}
className="text-[14px] leading-[18px]" className="min-w-0 flex-1 text-[14px] leading-[18px] whitespace-normal break-words"
style={{ color: labelColor }} style={{ color: labelColor }}
> >
{label} {label}
+2 -1
View File
@@ -99,6 +99,7 @@ function ChipView({
const baseClasses = ` const baseClasses = `
inline-flex inline-flex
max-w-full
items-center items-center
justify-center justify-center
rounded-[var(--measures-radius-full,9999px)] rounded-[var(--measures-radius-full,9999px)]
@@ -264,7 +265,7 @@ function ChipView({
onClick={handleClick} onClick={handleClick}
{...sharedA11y} {...sharedA11y}
> >
<span className="flex items-center justify-center">{label}</span> <span className="min-w-0 truncate">{label}</span>
{onRemove && !isDisabled && ( {onRemove && !isDisabled && (
<button <button
type="button" type="button"
@@ -45,30 +45,30 @@ function InfoMessageBoxView({
return ( return (
<div <div
className={`flex flex-col gap-[12px] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] w-full min-w-0 ${className}`} className={`flex w-full min-w-0 items-start gap-[var(--measures-spacing-200,8px)] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] ${className}`}
role="region" role="region"
aria-label={title} aria-label={title}
> >
<div className="flex items-center gap-[var(--measures-spacing-200,8px)] min-w-0"> <div
<div className="relative shrink-0 size-6 flex items-center justify-center"
className="relative shrink-0 size-6 flex items-center justify-center" data-name="Asset / Icon / exclamation"
data-name="Asset / Icon / exclamation" >
> {icon ?? <ExclamationIconInline />}
{icon ?? <ExclamationIconInline />} </div>
</div> <div className="flex min-w-0 flex-1 flex-col gap-[12px]">
<p className="text-small-label text-[var(--color-content-default-primary,white)] min-w-0"> <p className="text-small-label text-[var(--color-content-default-primary,white)] min-w-0 break-words">
{title} {title}
</p> </p>
</div> <div className="flex min-w-0 w-full flex-col gap-[12px] [&_label]:w-full [&_label]:min-w-0 [&_label]:gap-[6px] [&_label_span]:text-x-small-paragraph [&_label_span]:opacity-80">
<div className="flex flex-col gap-[12px] [&_label]:gap-[6px] [&_label_span]:text-x-small-paragraph [&_label_span]:opacity-80 pl-8"> <CheckboxGroup
<CheckboxGroup mode="standard"
mode="standard" value={checkedIds}
value={checkedIds} onChange={handleChange}
onChange={handleChange} options={options}
options={options} aria-label={title}
aria-label={title} className="flex w-full min-w-0 flex-col gap-[12px] !space-y-0"
className="flex flex-col gap-[12px] !space-y-0" />
/> </div>
</div> </div>
</div> </div>
); );
+4 -1
View File
@@ -157,7 +157,10 @@ const decisionRow = {
selectionIds: (s: CreateFlowState) => s.selectedDecisionApproachIds ?? [], selectionIds: (s: CreateFlowState) => s.selectedDecisionApproachIds ?? [],
selectedIdsStateKey: "selectedDecisionApproachIds", selectedIdsStateKey: "selectedDecisionApproachIds",
detailOverridesStateKey: "decisionApproachDetailsById", detailOverridesStateKey: "decisionApproachDetailsById",
stripSelectionKeys: ["selectedDecisionApproachIds"] as const, stripSelectionKeys: [
"selectedDecisionApproachIds",
"selectedDecisionKeyResourceIds",
] as const,
apiMethodSectionId: "decisionApproaches", apiMethodSectionId: "decisionApproaches",
} satisfies CustomRuleFacetRow; } satisfies CustomRuleFacetRow;
+149
View File
@@ -0,0 +1,149 @@
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
import decisionApproachesMessages from "../../messages/en/create/customRule/decisionApproaches.json";
export type DecisionApproachKeyResourceItem = {
id: string;
label: string;
};
function uniquePreserveOrder(values: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
function isKeyResourceItem(value: unknown): value is DecisionApproachKeyResourceItem {
if (!value || typeof value !== "object") return false;
const row = value as { id?: unknown; label?: unknown };
return typeof row.id === "string" && typeof row.label === "string";
}
/**
* Sidebar “key resource” checkboxes on the decision-approaches step.
* These labels are also offered as Applicable Scope chips on every approach.
*/
export function decisionApproachKeyResourceItems(): DecisionApproachKeyResourceItem[] {
const items = (
decisionApproachesMessages as {
messageBox?: { items?: unknown };
}
).messageBox?.items;
if (!Array.isArray(items)) return [];
return items.filter(isKeyResourceItem);
}
export function decisionApproachKeyResourceLabels(): string[] {
return decisionApproachKeyResourceItems().map((item) => item.label);
}
export function selectedKeyResourceLabelsFromCheckedIds(
checkedIds: readonly string[],
): string[] {
const idSet = new Set(checkedIds);
return decisionApproachKeyResourceItems()
.filter((item) => idSet.has(item.id))
.map((item) => item.label);
}
export function decisionApproachKeyResourceIdsFromLabels(
labels: readonly string[],
): string[] {
const labelSet = new Set(labels);
return decisionApproachKeyResourceItems()
.filter((item) => labelSet.has(item.label))
.map((item) => item.id);
}
export function stringArraysEqual(
a: readonly string[],
b: readonly string[],
): boolean {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
/**
* Offer the four sidebar labels as Applicable Scope chips without persisting
* them onto `applicableScope` (unchecked keys must not publish as defaults).
*/
export function withDecisionApproachKeyResourceScopes(
scopes: readonly string[],
): string[] {
return uniquePreserveOrder([
...scopes,
...decisionApproachKeyResourceLabels(),
]);
}
/**
* Keep non-key chip selections, then select the key-resource labels whose
* sidebar boxes are checked.
*/
export function applyDecisionApproachKeyResources(
entry: DecisionApproachDetailEntry,
selectedKeyResourceLabels: readonly string[],
): DecisionApproachDetailEntry {
const keyLabels = decisionApproachKeyResourceLabels();
const keySet = new Set(keyLabels);
const selectedSet = new Set(selectedKeyResourceLabels);
return {
...entry,
selectedApplicableScope: uniquePreserveOrder([
...entry.selectedApplicableScope.filter((scope) => !keySet.has(scope)),
...keyLabels.filter((label) => selectedSet.has(label)),
]),
};
}
export function withoutDecisionApproachKeyResourceLabels(
scopes: readonly string[],
): string[] {
const keySet = new Set(decisionApproachKeyResourceLabels());
return scopes.filter((scope) => !keySet.has(scope));
}
/**
* Publish selected original scopes (or all originals when none are picked)
* plus any checked key-resource labels. Checking a key resource must not drop
* unselected default scopes.
*/
export function decisionApproachScopeForPublish(
applicableScope: readonly unknown[],
selectedApplicableScope: readonly unknown[],
): string[] {
const asStrings = (values: readonly unknown[]): string[] =>
values.filter((value): value is string => typeof value === "string");
const keySet = new Set(decisionApproachKeyResourceLabels());
const original = withoutDecisionApproachKeyResourceLabels(
asStrings(applicableScope),
);
const selected = asStrings(selectedApplicableScope);
const selectedOther = withoutDecisionApproachKeyResourceLabels(selected);
const selectedKeys = selected.filter((scope) => keySet.has(scope));
return uniquePreserveOrder([
...(selectedOther.length > 0 ? selectedOther : original),
...selectedKeys,
]);
}
export function syncDecisionApproachKeyResourceDetails(
detailsById: Record<string, DecisionApproachDetailEntry> | undefined,
extraIds: readonly string[],
selectedKeyResourceLabels: readonly string[],
seed: (_id: string) => DecisionApproachDetailEntry,
): Record<string, DecisionApproachDetailEntry> {
const next: Record<string, DecisionApproachDetailEntry> = {};
const ids = new Set<string>([...Object.keys(detailsById ?? {}), ...extraIds]);
for (const id of ids) {
next[id] = applyDecisionApproachKeyResources(
detailsById?.[id] ?? seed(id),
selectedKeyResourceLabels,
);
}
return next;
}
@@ -10,6 +10,7 @@ import {
decisionApproachPresetFor, decisionApproachPresetFor,
membershipPresetFor, membershipPresetFor,
} from "./finalReviewChipPresets"; } from "./finalReviewChipPresets";
import { withoutDecisionApproachKeyResourceLabels } from "./decisionApproachKeyResources";
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean { function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@@ -51,8 +52,14 @@ export function decisionApproachFacetMatchesPreset(
const p = decisionApproachPresetFor(cardId); const p = decisionApproachPresetFor(cardId);
return ( return (
details.corePrinciple === p.corePrinciple && details.corePrinciple === p.corePrinciple &&
stringArraysEqual(details.applicableScope, p.applicableScope) && stringArraysEqual(
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) && withoutDecisionApproachKeyResourceLabels(details.applicableScope),
withoutDecisionApproachKeyResourceLabels(p.applicableScope),
) &&
stringArraysEqual(
withoutDecisionApproachKeyResourceLabels(details.selectedApplicableScope),
withoutDecisionApproachKeyResourceLabels(p.selectedApplicableScope),
) &&
details.stepByStepInstructions === p.stepByStepInstructions && details.stepByStepInstructions === p.stepByStepInstructions &&
details.consensusLevel === p.consensusLevel && details.consensusLevel === p.consensusLevel &&
details.objectionsDeadlocks === p.objectionsDeadlocks details.objectionsDeadlocks === p.objectionsDeadlocks
@@ -5,6 +5,7 @@ import type {
} from "../../app/components/type/CommunityRule/CommunityRule.types"; } from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload"; import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import { decisionApproachScopeForPublish } from "./decisionApproachKeyResources";
import { templateCategoryToGroupKey } from "./templateReviewMapping"; import { templateCategoryToGroupKey } from "./templateReviewMapping";
/** Uses filename extension and/or URL path so uploads render as `<img>` vs file link on read-only surfaces. */ /** Uses filename extension and/or URL path so uploads render as `<img>` vs file link on read-only surfaces. */
@@ -243,9 +244,14 @@ export function sectionFromDecision(
for (const m of ms) { for (const m of ms) {
const sec = m.sections as unknown as Record<string, unknown>; const sec = m.sections as unknown as Record<string, unknown>;
const merged: Record<string, unknown> = { ...sec }; const merged: Record<string, unknown> = { ...sec };
const scope = const scope = formatScopePayload(
formatScopePayload(sec.selectedApplicableScope) ?? decisionApproachScopeForPublish(
formatScopePayload(sec.applicableScope); Array.isArray(sec.applicableScope) ? sec.applicableScope : [],
Array.isArray(sec.selectedApplicableScope)
? sec.selectedApplicableScope
: [],
),
);
if (scope) merged.applicableScope = scope; if (scope) merged.applicableScope = scope;
delete merged.selectedApplicableScope; delete merged.selectedApplicableScope;
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
@@ -117,6 +117,7 @@ export const createFlowStateSchema = z
selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(), selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(), selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(), selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
selectedDecisionKeyResourceIds: z.array(z.string().max(80)).max(20).optional(),
selectedConflictManagementIds: z.array(z.string()).max(200).optional(), selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
communicationMethodDetailsById: z communicationMethodDetailsById: z
.record(communicationMethodDetailEntrySchema) .record(communicationMethodDetailEntrySchema)
+7
View File
@@ -34,4 +34,11 @@ describe("InfoMessageBox", () => {
await u.click(checkbox); await u.click(checkbox);
expect(onCheckboxChange).toHaveBeenCalled(); expect(onCheckboxChange).toHaveBeenCalled();
}); });
it("keeps the checkbox column shrinkable so labels wrap", () => {
render(<InfoMessageBox title="Important" items={items} />);
const region = screen.getByRole("region", { name: "Important" });
expect(region).toHaveClass("min-w-0");
expect(region).toHaveClass("w-full");
});
}); });
+87
View File
@@ -272,4 +272,91 @@ describe("Create flow decision-approaches page", () => {
await user.click(amendCheckbox); await user.click(amendCheckbox);
expect(amendCheckbox).toBeChecked(); expect(amendCheckbox).toBeChecked();
}); });
test("each approach modal includes the key-resource applicable-scope chips", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByRole("button", {
name: "Select Amend your CommunityRule",
}),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: "Select Steward finances" }),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", {
name: "Select Project level decisions",
}),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", {
name: "Select Discipline and member termination",
}),
).toBeInTheDocument();
});
test("checking a key-resource box selects that chip on every approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("checkbox", { name: "Steward finances" }),
);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const lazyDialog = await screen.findByRole("dialog");
expect(
within(lazyDialog).getByRole("button", {
name: "Deselect Steward finances",
}),
).toBeInTheDocument();
await user.click(within(lazyDialog).getByRole("button", { name: "Close dialog" }));
await user.click(
screen.getByRole("button", {
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
);
const doocracyDialog = await screen.findByRole("dialog");
expect(
within(doocracyDialog).getByRole("button", {
name: "Deselect Steward finances",
}),
).toBeInTheDocument();
});
test("choosing a key-resource chip checks the matching sidebar box", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
await user.click(
screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
}),
);
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", {
name: "Select Discipline and member termination",
}),
);
expect(
screen.getByRole("checkbox", {
name: "Discipline and member termination",
}),
).toBeChecked();
});
}); });
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
applyDecisionApproachKeyResources,
decisionApproachKeyResourceIdsFromLabels,
decisionApproachScopeForPublish,
decisionApproachKeyResourceItems,
decisionApproachKeyResourceLabels,
selectedKeyResourceLabelsFromCheckedIds,
stringArraysEqual,
syncDecisionApproachKeyResourceDetails,
withDecisionApproachKeyResourceScopes,
} from "../../lib/create/decisionApproachKeyResources";
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
const emptyEntry = (): DecisionApproachDetailEntry => ({
corePrinciple: "p",
applicableScope: ["Daily Operations"],
selectedApplicableScope: [],
stepByStepInstructions: "s",
consensusLevel: 75,
objectionsDeadlocks: "o",
});
describe("decisionApproachKeyResources", () => {
it("reads the four key-resource items from messages", () => {
expect(decisionApproachKeyResourceItems()).toEqual([
{ id: "amend", label: "Amend your CommunityRule" },
{ id: "finances", label: "Steward finances" },
{ id: "project", label: "Project level decisions" },
{ id: "discipline", label: "Discipline and member termination" },
]);
});
it("selects checked key-resource labels without rewriting applicableScope", () => {
const next = applyDecisionApproachKeyResources(emptyEntry(), [
"Steward finances",
]);
expect(next.applicableScope).toEqual(["Daily Operations"]);
expect(next.selectedApplicableScope).toEqual(["Steward finances"]);
});
it("keeps non-key chip selections when syncing sidebar checks", () => {
const next = applyDecisionApproachKeyResources(
{
...emptyEntry(),
applicableScope: ["Steward finances", "Daily Operations"],
selectedApplicableScope: ["Daily Operations"],
},
["Steward finances"],
);
expect(next.applicableScope).toEqual([
"Steward finances",
"Daily Operations",
]);
expect(next.selectedApplicableScope).toEqual([
"Daily Operations",
"Steward finances",
]);
});
it("publishes default scopes plus checked key resources", () => {
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Steward finances"],
),
).toEqual([
"Daily Operations",
"Minor Expenditures",
"Steward finances",
]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
["Daily Operations", "Steward finances"],
),
).toEqual(["Daily Operations", "Steward finances"]);
expect(
decisionApproachScopeForPublish(
["Daily Operations", "Minor Expenditures"],
[],
),
).toEqual(["Daily Operations", "Minor Expenditures"]);
});
it("maps checkbox ids to labels and back", () => {
expect(selectedKeyResourceLabelsFromCheckedIds(["amend", "project"])).toEqual(
["Amend your CommunityRule", "Project level decisions"],
);
expect(
decisionApproachKeyResourceIdsFromLabels([
"Project level decisions",
"Unknown",
"Amend your CommunityRule",
]),
).toEqual(["amend", "project"]);
});
it("syncs existing and selected approach details", () => {
const next = syncDecisionApproachKeyResourceDetails(
{ "lazy-consensus": emptyEntry() },
["do-ocracy"],
["Amend your CommunityRule"],
(id) => ({
...emptyEntry(),
corePrinciple: id,
applicableScope: ["Volunteer Tasks"],
}),
);
expect(next["lazy-consensus"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
]);
expect(next["do-ocracy"]?.corePrinciple).toBe("do-ocracy");
expect(next["do-ocracy"]?.applicableScope).toEqual(["Volunteer Tasks"]);
expect(next["do-ocracy"]?.selectedApplicableScope).toEqual([
"Amend your CommunityRule",
]);
});
it("compares string arrays by order", () => {
expect(stringArraysEqual(["a", "b"], ["a", "b"])).toBe(true);
expect(stringArraysEqual(["a", "b"], ["b", "a"])).toBe(false);
expect(decisionApproachKeyResourceLabels()).toHaveLength(4);
});
it("unions key-resource labels onto an existing scope list", () => {
expect(
withDecisionApproachKeyResourceScopes(["Daily Operations"]),
).toEqual([
"Daily Operations",
"Amend your CommunityRule",
"Steward finances",
"Project level decisions",
"Discipline and member termination",
]);
});
});
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets"; import {
import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId"; communicationPresetFor,
decisionApproachPresetFor,
} from "../../lib/create/finalReviewChipPresets";
import {
communicationMethodFacetMatchesPreset,
decisionApproachFacetMatchesPreset,
} from "../../lib/create/methodCardFacetMatchesPresetForId";
const uuid = "550e8400-e29b-41d4-a716-446655440000"; const uuid = "550e8400-e29b-41d4-a716-446655440000";
@@ -19,4 +25,23 @@ describe("methodCardFacetMatchesPresetForId", () => {
), ),
).toBe(false); ).toBe(false);
}); });
it("decision approaches: ignores key-resource chip selections", () => {
const p = decisionApproachPresetFor(uuid);
expect(
decisionApproachFacetMatchesPreset(
{
...p,
selectedApplicableScope: ["Steward finances"],
},
uuid,
),
).toBe(true);
expect(
decisionApproachFacetMatchesPreset(
{ ...p, corePrinciple: "edited" },
uuid,
),
).toBe(false);
});
}); });
@@ -12,6 +12,7 @@ describe("stripCustomRuleSelectionFields", () => {
selectedCommunicationMethodIds: ["signal"], selectedCommunicationMethodIds: ["signal"],
selectedMembershipMethodIds: ["x"], selectedMembershipMethodIds: ["x"],
selectedDecisionApproachIds: ["y"], selectedDecisionApproachIds: ["y"],
selectedDecisionKeyResourceIds: ["amend"],
selectedConflictManagementIds: ["z"], selectedConflictManagementIds: ["z"],
methodSectionsPinCommitted: { communication: true }, methodSectionsPinCommitted: { communication: true },
coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } }, coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } },
@@ -38,6 +39,7 @@ describe("stripCustomRuleSelectionFields", () => {
expect(out.selectedCommunicationMethodIds).toBeUndefined(); expect(out.selectedCommunicationMethodIds).toBeUndefined();
expect(out.selectedMembershipMethodIds).toBeUndefined(); expect(out.selectedMembershipMethodIds).toBeUndefined();
expect(out.selectedDecisionApproachIds).toBeUndefined(); expect(out.selectedDecisionApproachIds).toBeUndefined();
expect(out.selectedDecisionKeyResourceIds).toBeUndefined();
expect(out.selectedConflictManagementIds).toBeUndefined(); expect(out.selectedConflictManagementIds).toBeUndefined();
expect(out.methodSectionsPinCommitted).toBeUndefined(); expect(out.methodSectionsPinCommitted).toBeUndefined();
expect(out.coreValueDetailsByChipId).toBeUndefined(); expect(out.coreValueDetailsByChipId).toBeUndefined();