Compare commits
10
Commits
75ba0a214f
...
d9751d7964
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9751d7964 | ||
|
|
63e0f77998 | ||
|
|
8bdd5040c6 | ||
|
|
712437edc7 | ||
|
|
963fa03c5c | ||
|
|
b1ca1a748e | ||
|
|
38fe0e7e9b | ||
|
|
e416f37940 | ||
|
|
440b6a9657 | ||
|
|
ef60175805 |
@@ -47,6 +47,9 @@ npm-cache/
|
|||||||
# Per-article body ornaments (Figma Content page Template image fills)
|
# Per-article body ornaments (Figma Content page Template image fills)
|
||||||
!public/content/blog/*-ornament-*.png
|
!public/content/blog/*-ornament-*.png
|
||||||
|
|
||||||
|
# Marketing book cover (raster; see docs/guides/static-assets.md)
|
||||||
|
!public/assets/marketing/community-rules-cover.png
|
||||||
|
|
||||||
# Visual regression snapshots (allow these)
|
# Visual regression snapshots (allow these)
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/
|
!tests/e2e/visual-regression.spec.ts-snapshots/
|
||||||
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
!tests/e2e/visual-regression.spec.ts-snapshots/*.png
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function ApplicableScopeFieldComponent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
<div className={`flex flex-col gap-2 ${className}`.trim()}>
|
||||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
<InputLabel label={label} size="s" palette="default" />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{scopes.map((scope) => {
|
{scopes.map((scope) => {
|
||||||
const isSelected = selectedScopes.includes(scope);
|
const isSelected = selectedScopes.includes(scope);
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+1
-2
@@ -60,7 +60,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<div key={block.id} className="flex flex-col gap-2">
|
<div key={block.id} className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +122,6 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -169,6 +167,7 @@ function CustomMethodCardFieldBlocksSummaryViewComponent({
|
|||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
key={block.id}
|
key={block.id}
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
|
helpIcon={false}
|
||||||
value={block.defaultPercent}
|
value={block.defaultPercent}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,6 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={block.blockTitle}
|
label={block.blockTitle}
|
||||||
helpIcon
|
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
/>
|
/>
|
||||||
@@ -77,6 +76,7 @@ function CustomMethodCardUploadBlockRowViewComponent({
|
|||||||
) : (
|
) : (
|
||||||
<Upload
|
<Upload
|
||||||
active={!busy}
|
active={!busy}
|
||||||
|
showHelpIcon={false}
|
||||||
hintText={busy ? uploadingHint : uploadHint}
|
hintText={busy ? uploadingHint : uploadHint}
|
||||||
onClick={onUploadClick}
|
onClick={onUploadClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
@@ -388,6 +388,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
|
|
||||||
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
|
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
|
||||||
setEditingBlockId(null);
|
setEditingBlockId(null);
|
||||||
|
setAddFieldExpanded(false);
|
||||||
resetFieldTypeDrafts();
|
resetFieldTypeDrafts();
|
||||||
setFieldTypeModal(ft);
|
setFieldTypeModal(ft);
|
||||||
fieldModalSnapshotRef.current = JSON.stringify({
|
fieldModalSnapshotRef.current = JSON.stringify({
|
||||||
@@ -531,6 +532,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
|||||||
});
|
});
|
||||||
setFieldTypeModal(null);
|
setFieldTypeModal(null);
|
||||||
setEditingBlockId(null);
|
setEditingBlockId(null);
|
||||||
|
setAddFieldExpanded(false);
|
||||||
fieldModalSnapshotRef.current = null;
|
fieldModalSnapshotRef.current = null;
|
||||||
}, [
|
}, [
|
||||||
badgeBlockTitle,
|
badgeBlockTitle,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function CustomMethodCardWizardViewComponent({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!fieldTypeModal && wizardStep === 3 ? (
|
{!fieldTypeModal && wizardStep === 3 ? (
|
||||||
<div className="flex w-full flex-col gap-4 pt-1">
|
<div className="flex w-full flex-col gap-6">
|
||||||
{draftFieldBlocks.length > 0 ? (
|
{draftFieldBlocks.length > 0 ? (
|
||||||
<CustomMethodCardWizardBlocksList
|
<CustomMethodCardWizardBlocksList
|
||||||
blocks={draftFieldBlocks}
|
blocks={draftFieldBlocks}
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ function CustomMethodCardWizardBlocksListViewComponent({
|
|||||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
className="flex list-none flex-col gap-2 p-0 pt-1"
|
className="flex list-none flex-col gap-2 p-0"
|
||||||
aria-label={listLabel}
|
aria-label={listLabel}
|
||||||
>
|
>
|
||||||
{blocks.map((block, index) => {
|
{blocks.map((block, index) => {
|
||||||
|
|||||||
+5
-10
@@ -51,15 +51,11 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={textBlockTitle}
|
value={textBlockTitle}
|
||||||
onChange={onTextBlockTitleChange}
|
onChange={onTextBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
|
||||||
label={copy.text.placeholderLabel}
|
{copy.text.placeholderLabel}
|
||||||
helpIcon
|
</label>
|
||||||
size="s"
|
|
||||||
palette="default"
|
|
||||||
/>
|
|
||||||
<TextArea
|
<TextArea
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
appearance="embedded"
|
appearance="embedded"
|
||||||
@@ -82,7 +78,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
label={copy.badges.blockTitleLabel}
|
label={copy.badges.blockTitleLabel}
|
||||||
helpIcon
|
|
||||||
helperText={copy.requiredHint}
|
helperText={copy.requiredHint}
|
||||||
size="s"
|
size="s"
|
||||||
palette="default"
|
palette="default"
|
||||||
@@ -127,7 +122,6 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={uploadBlockTitle}
|
value={uploadBlockTitle}
|
||||||
onChange={onUploadBlockTitleChange}
|
onChange={onUploadBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
{hasUploadPreview ? (
|
{hasUploadPreview ? (
|
||||||
<div className="relative inline-block max-w-full">
|
<div className="relative inline-block max-w-full">
|
||||||
@@ -158,6 +152,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
) : (
|
) : (
|
||||||
<Upload
|
<Upload
|
||||||
active={!uploadPersisting}
|
active={!uploadPersisting}
|
||||||
|
showHelpIcon={false}
|
||||||
hintText={
|
hintText={
|
||||||
uploadPersisting && uploadBusyHint
|
uploadPersisting && uploadBusyHint
|
||||||
? uploadBusyHint
|
? uploadBusyHint
|
||||||
@@ -188,10 +183,10 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
|||||||
value={proportionBlockTitle}
|
value={proportionBlockTitle}
|
||||||
onChange={onProportionBlockTitleChange}
|
onChange={onProportionBlockTitleChange}
|
||||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||||
showHelpIcon
|
|
||||||
/>
|
/>
|
||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
label={copy.proportion.defaultLabel}
|
label={copy.proportion.defaultLabel}
|
||||||
|
helpIcon={false}
|
||||||
value={proportionDefault}
|
value={proportionDefault}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ export function FinalReviewChipEditModal({
|
|||||||
true,
|
true,
|
||||||
coreCustomizeSnapshotRef.current,
|
coreCustomizeSnapshotRef.current,
|
||||||
draft?.groupKey === "coreValues" ? draft.value : null,
|
draft?.groupKey === "coreValues" ? draft.value : null,
|
||||||
null,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
@@ -605,7 +605,7 @@ export function FinalReviewChipEditModal({
|
|||||||
true,
|
true,
|
||||||
coreCustomizeSnapshotRef.current,
|
coreCustomizeSnapshotRef.current,
|
||||||
draft?.groupKey === "coreValues" ? draft.value : null,
|
draft?.groupKey === "coreValues" ? draft.value : null,
|
||||||
null,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
@@ -622,6 +622,7 @@ export function FinalReviewChipEditModal({
|
|||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
draft,
|
draft,
|
||||||
|
draftFieldBlocks,
|
||||||
finalizeModalClose,
|
finalizeModalClose,
|
||||||
onInteract,
|
onInteract,
|
||||||
replaceState,
|
replaceState,
|
||||||
@@ -647,7 +648,7 @@ export function FinalReviewChipEditModal({
|
|||||||
true,
|
true,
|
||||||
coreCustomizeSnapshotRef.current,
|
coreCustomizeSnapshotRef.current,
|
||||||
draft.value,
|
draft.value,
|
||||||
null,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
@@ -696,6 +697,7 @@ export function FinalReviewChipEditModal({
|
|||||||
confirmDiscard,
|
confirmDiscard,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
draft,
|
draft,
|
||||||
|
draftFieldBlocks,
|
||||||
modalKebabMenu.duplicateTitleSuffix,
|
modalKebabMenu.duplicateTitleSuffix,
|
||||||
onEditTargetChange,
|
onEditTargetChange,
|
||||||
onInteract,
|
onInteract,
|
||||||
@@ -790,7 +792,7 @@ export function FinalReviewChipEditModal({
|
|||||||
});
|
});
|
||||||
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||||
draft.value,
|
draft.value,
|
||||||
null,
|
draftFieldBlocks,
|
||||||
customizeHeaderDraft ?? {
|
customizeHeaderDraft ?? {
|
||||||
title: target.chipLabel,
|
title: target.chipLabel,
|
||||||
description: "",
|
description: "",
|
||||||
@@ -802,6 +804,7 @@ export function FinalReviewChipEditModal({
|
|||||||
coreCustomizeSaveDisabled,
|
coreCustomizeSaveDisabled,
|
||||||
customizeHeaderDraft,
|
customizeHeaderDraft,
|
||||||
draft,
|
draft,
|
||||||
|
draftFieldBlocks,
|
||||||
onInteract,
|
onInteract,
|
||||||
onSave,
|
onSave,
|
||||||
state.customMethodCardFieldBlocksById,
|
state.customMethodCardFieldBlocksById,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default function MethodCardCustomizeModalHeader({
|
|||||||
value={titleValue}
|
value={titleValue}
|
||||||
onChange={(e) => onTitleChange(e.target.value)}
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
inputSize="medium"
|
inputSize="medium"
|
||||||
|
showHelpIcon={false}
|
||||||
/>
|
/>
|
||||||
{showDescription ? (
|
{showDescription ? (
|
||||||
<ModalTextAreaField
|
<ModalTextAreaField
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared "labelled text area" field used by every create flow modal section.
|
* 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
|
* Pairs an `InputLabel` with a `TextArea` set to the embedded appearance —
|
||||||
* appearance — matching the Figma "Control / Text Area" pattern.
|
* matching the Figma "Control / Text Area" pattern. Section help marks stay
|
||||||
|
* off until tooltip behavior ships.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useId } from "react";
|
import { memo, useId } from "react";
|
||||||
@@ -13,7 +14,7 @@ import InputLabel from "../../../components/type/InputLabel";
|
|||||||
export interface ModalTextAreaFieldProps {
|
export interface ModalTextAreaFieldProps {
|
||||||
/** Label rendered above the text area. */
|
/** Label rendered above the text area. */
|
||||||
label: string;
|
label: string;
|
||||||
/** Show the help "?" icon next to the label (default `true`). */
|
/** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
|
||||||
helpIcon?: boolean;
|
helpIcon?: boolean;
|
||||||
/** Current text value. */
|
/** Current text value. */
|
||||||
value: string;
|
value: string;
|
||||||
@@ -30,7 +31,7 @@ export interface ModalTextAreaFieldProps {
|
|||||||
|
|
||||||
function ModalTextAreaFieldComponent({
|
function ModalTextAreaFieldComponent({
|
||||||
label,
|
label,
|
||||||
helpIcon = true,
|
helpIcon = false,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
@@ -76,6 +82,7 @@ function DecisionApproachEditFieldsComponent({
|
|||||||
/>
|
/>
|
||||||
<IncrementerBlock
|
<IncrementerBlock
|
||||||
label={t.sectionHeadings.consensusLevel}
|
label={t.sectionHeadings.consensusLevel}
|
||||||
|
helpIcon={false}
|
||||||
value={value.consensusLevel}
|
value={value.consensusLevel}
|
||||||
min={CONSENSUS_LEVEL_MIN}
|
min={CONSENSUS_LEVEL_MIN}
|
||||||
max={CONSENSUS_LEVEL_MAX}
|
max={CONSENSUS_LEVEL_MAX}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
|
||||||
mergeCompactCardIdsWithPinnedSelected,
|
|
||||||
orderRankedMethodsWithPinnedSelection,
|
|
||||||
} from "../../../../lib/create/methodCardDisplayOrder";
|
|
||||||
import {
|
import {
|
||||||
deriveCompactCards,
|
deriveCompactCards,
|
||||||
rankMethodsByScore,
|
rankMethodsByScore,
|
||||||
@@ -15,10 +12,10 @@ import {
|
|||||||
type MethodEntry = { id: string; label: string; supportText: string };
|
type MethodEntry = { id: string; label: string; supportText: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
|
* Applies score ranking and compact-slot rules. Expanded CardStack order stays
|
||||||
* `selected*Ids` order (most-recent add at index 0 via
|
* the ranked catalog (selected cards keep their place). Compact slots still
|
||||||
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
|
* pin selected ids first so a pick outside the unpinned top-N remains visible
|
||||||
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
|
* when the stack is collapsed.
|
||||||
*/
|
*/
|
||||||
export function useMethodCardDeckOrdering(
|
export function useMethodCardDeckOrdering(
|
||||||
section: RecommendationSection,
|
section: RecommendationSection,
|
||||||
@@ -35,16 +32,7 @@ export function useMethodCardDeckOrdering(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectionShowcaseActive = selectedIds.length > 0;
|
const selectionShowcaseActive = selectedIds.length > 0;
|
||||||
|
const displayMethods = rankedMethods;
|
||||||
const displayMethods = useMemo(
|
|
||||||
() =>
|
|
||||||
orderRankedMethodsWithPinnedSelection(
|
|
||||||
rankedMethods,
|
|
||||||
selectedIds,
|
|
||||||
selectionShowcaseActive,
|
|
||||||
),
|
|
||||||
[rankedMethods, selectedIds, selectionShowcaseActive],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -60,13 +48,13 @@ export function useMethodCardDeckOrdering(
|
|||||||
const compactCardIds = useMemo(
|
const compactCardIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
mergeCompactCardIdsWithPinnedSelected(
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
displayMethods.map((m) => m.id),
|
rankedMethods.map((m) => m.id),
|
||||||
baseCompactCardIds,
|
baseCompactCardIds,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
selectionShowcaseActive,
|
selectionShowcaseActive,
|
||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
[rankedMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sampleCards = useMemo(
|
const sampleCards = useMemo(
|
||||||
|
|||||||
@@ -643,16 +643,9 @@ export function CommunicationMethodsScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pendingDraft) {
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
customizeSnapshotRef.current = null;
|
||||||
pendingDraft,
|
void handleCreateModalClose();
|
||||||
persistWizardBlocks ? draftFieldBlocks : null,
|
|
||||||
customizeSnapshotRef.current?.headerDraft ?? {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +733,7 @@ export function CommunicationMethodsScreen() {
|
|||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
|||||||
@@ -644,16 +644,9 @@ export function ConflictManagementScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pendingDraft) {
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
customizeSnapshotRef.current = null;
|
||||||
pendingDraft,
|
void handleCreateModalClose();
|
||||||
persistWizardBlocks ? draftFieldBlocks : null,
|
|
||||||
customizeSnapshotRef.current?.headerDraft ?? {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,6 +734,7 @@ export function ConflictManagementScreen() {
|
|||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
|||||||
@@ -637,16 +637,9 @@ export function MembershipMethodsScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (pendingDraft) {
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
customizeSnapshotRef.current = null;
|
||||||
pendingDraft,
|
void handleCreateModalClose();
|
||||||
persistWizardBlocks ? draftFieldBlocks : null,
|
|
||||||
customizeSnapshotRef.current?.headerDraft ?? {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +727,7 @@ export function MembershipMethodsScreen() {
|
|||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ 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,
|
||||||
|
decisionApproachKeyResourceCheckboxIds,
|
||||||
|
selectedKeyResourceLabelsFromCheckedIds,
|
||||||
|
} 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 +76,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 +92,12 @@ export function DecisionApproachesScreen() {
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||||
|
const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({
|
||||||
|
detailsById: state.decisionApproachDetailsById,
|
||||||
|
selectedApproachIds: selectedIds,
|
||||||
|
reminderIds: state.selectedDecisionKeyResourceIds,
|
||||||
|
openDraft: pendingDraft,
|
||||||
|
});
|
||||||
|
|
||||||
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -137,22 +145,34 @@ 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),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateState({
|
||||||
|
selectedDecisionKeyResourceIds: nextCheckedIds,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[markCreateFlowInteraction],
|
[
|
||||||
|
markCreateFlowInteraction,
|
||||||
|
messageBoxCheckedIds,
|
||||||
|
pendingDraft,
|
||||||
|
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) {
|
if (saved) {
|
||||||
return {
|
return structuredClone(saved);
|
||||||
...saved,
|
|
||||||
applicableScope: [...saved.applicableScope],
|
|
||||||
selectedApplicableScope: [...saved.selectedApplicableScope],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return decisionApproachPresetFor(id);
|
return decisionApproachPresetFor(id);
|
||||||
},
|
},
|
||||||
@@ -625,31 +645,30 @@ export function DecisionApproachesScreen() {
|
|||||||
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
|
||||||
|
|
||||||
if (selectedIds.includes(pendingCardId)) {
|
if (selectedIds.includes(pendingCardId)) {
|
||||||
|
replaceState((prev) => {
|
||||||
if (persistWizardBlocks) {
|
if (persistWizardBlocks) {
|
||||||
updateState({
|
return {
|
||||||
|
...prev,
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
} else if (pendingDraft) {
|
}
|
||||||
updateState({
|
if (!pendingDraft) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
decisionApproachDetailsById: {
|
decisionApproachDetailsById: {
|
||||||
...(state.decisionApproachDetailsById ?? {}),
|
...(prev.decisionApproachDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: structuredClone(pendingDraft),
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
if (pendingDraft) {
|
customizeSnapshotRef.current = null;
|
||||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
void handleCreateModalClose();
|
||||||
pendingDraft,
|
|
||||||
persistWizardBlocks ? draftFieldBlocks : null,
|
|
||||||
customizeSnapshotRef.current?.headerDraft ?? {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,24 +676,25 @@ export function DecisionApproachesScreen() {
|
|||||||
void handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState({
|
replaceState((prev) => ({
|
||||||
|
...prev,
|
||||||
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
|
||||||
selectedIds,
|
prev.selectedDecisionApproachIds ?? [],
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
),
|
),
|
||||||
decisionApproachDetailsById: {
|
decisionApproachDetailsById: {
|
||||||
...(state.decisionApproachDetailsById ?? {}),
|
...(prev.decisionApproachDetailsById ?? {}),
|
||||||
[pendingCardId]: pendingDraft,
|
[pendingCardId]: structuredClone(pendingDraft),
|
||||||
},
|
},
|
||||||
...(persistWizardBlocks
|
...(persistWizardBlocks
|
||||||
? {
|
? {
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
}));
|
||||||
pendingEphemeralDuplicateIdRef.current = null;
|
pendingEphemeralDuplicateIdRef.current = null;
|
||||||
customizeSnapshotRef.current = null;
|
customizeSnapshotRef.current = null;
|
||||||
void handleCreateModalClose();
|
void handleCreateModalClose();
|
||||||
@@ -685,9 +705,8 @@ export function DecisionApproachesScreen() {
|
|||||||
modalUsesWizardFieldBlocksBody,
|
modalUsesWizardFieldBlocksBody,
|
||||||
pendingCardId,
|
pendingCardId,
|
||||||
pendingDraft,
|
pendingDraft,
|
||||||
|
replaceState,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
state,
|
|
||||||
updateState,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const modalConfig = pendingCardId
|
const modalConfig = pendingCardId
|
||||||
@@ -778,6 +797,7 @@ export function DecisionApproachesScreen() {
|
|||||||
title={modalConfig.title}
|
title={modalConfig.title}
|
||||||
description={modalConfig.description}
|
description={modalConfig.description}
|
||||||
nextButtonText={modalConfig.nextButtonText}
|
nextButtonText={modalConfig.nextButtonText}
|
||||||
|
showBackButton={false}
|
||||||
showNextButton={showMethodModalPrimary}
|
showNextButton={showMethodModalPrimary}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
MAX_SELECTED_CORE_VALUES,
|
MAX_SELECTED_CORE_VALUES,
|
||||||
removeCoreValueChipFromDraft,
|
removeCoreValueChipFromDraft,
|
||||||
} from "../../../../../lib/create/coreValueChipFacet";
|
} from "../../../../../lib/create/coreValueChipFacet";
|
||||||
|
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||||
|
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||||
import {
|
import {
|
||||||
buildMethodCardWizardInitialValues,
|
buildMethodCardWizardInitialValues,
|
||||||
coreValueDetailsFromWizardFieldBlocks,
|
coreValueDetailsFromWizardFieldBlocks,
|
||||||
@@ -41,11 +43,13 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
|||||||
*
|
*
|
||||||
* - `pending` — preset chip just selected; modal opened to capture
|
* - `pending` — preset chip just selected; modal opened to capture
|
||||||
* meaning/signals. Close (X) confirms, then unselects the chip.
|
* meaning/signals. Close (X) confirms, then unselects the chip.
|
||||||
* - `customPending` — brand-new custom chip just created via the Add
|
* - `customPending` — leftover inline custom-chip drafts (older sessions).
|
||||||
* value flow; modal opened with empty fields. Dismiss = drop the
|
* Dismiss = drop the chip entirely.
|
||||||
* chip entirely (it was never confirmed via the Add Value button).
|
|
||||||
* - `editing` — chip already exists & is selected; modal reopened to
|
* - `editing` — chip already exists & is selected; modal reopened to
|
||||||
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
|
||||||
|
*
|
||||||
|
* **Add value** (and the header "add" link) opens an empty custom-policy
|
||||||
|
* wizard. Finalize writes a selected chip named from the wizard title.
|
||||||
*/
|
*/
|
||||||
type ModalSession = "pending" | "customPending" | "editing";
|
type ModalSession = "pending" | "customPending" | "editing";
|
||||||
|
|
||||||
@@ -489,12 +493,57 @@ export function CoreValuesSelectScreen() {
|
|||||||
description: string;
|
description: string;
|
||||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||||
}) => {
|
}) => {
|
||||||
const chipId = wizardCustomizeChipId ?? activeModalChipId;
|
|
||||||
if (!chipId) return;
|
|
||||||
markCreateFlowInteraction();
|
|
||||||
pendingEphemeralCoreDuplicateRef.current = null;
|
|
||||||
const trimmedTitle = title.trim();
|
const trimmedTitle = title.trim();
|
||||||
const trimmedDescription = description.trim();
|
const trimmedDescription = description.trim();
|
||||||
|
if (!trimmedTitle) return;
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
pendingEphemeralCoreDuplicateRef.current = null;
|
||||||
|
|
||||||
|
const existingId = wizardCustomizeChipId;
|
||||||
|
if (!existingId) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const nextDetails = {
|
||||||
|
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, EMPTY_DETAIL),
|
||||||
|
...(trimmedDescription.length > 0
|
||||||
|
? { supportText: trimmedDescription }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
replaceState((prev) => {
|
||||||
|
const sel = prev.selectedCoreValueIds ?? [];
|
||||||
|
if (sel.length >= MAX_CORE_VALUES) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
||||||
|
snap.push({
|
||||||
|
id,
|
||||||
|
label: trimmedTitle,
|
||||||
|
state: "selected",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, id),
|
||||||
|
coreValuesChipsSnapshot: snap,
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
...(prev.coreValueDetailsByChipId ?? {}),
|
||||||
|
[id]: nextDetails,
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
|
[id]: structuredClone(fieldBlocks),
|
||||||
|
},
|
||||||
|
customMethodCardMetaById: methodCardMetaWithCustomizeHeader(
|
||||||
|
prev.customMethodCardMetaById,
|
||||||
|
id,
|
||||||
|
{ title: trimmedTitle, description: trimmedDescription },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setAddCustomWizardOpen(false);
|
||||||
|
setWizardCustomizeChipId(null);
|
||||||
|
setWizardInitialValues(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextDetails = {
|
const nextDetails = {
|
||||||
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
|
...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft),
|
||||||
...(trimmedDescription.length > 0
|
...(trimmedDescription.length > 0
|
||||||
@@ -503,8 +552,8 @@ export function CoreValuesSelectScreen() {
|
|||||||
};
|
};
|
||||||
replaceState((prev) => {
|
replaceState((prev) => {
|
||||||
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
const snap = [...(prev.coreValuesChipsSnapshot ?? [])];
|
||||||
const i = snap.findIndex((r) => r.id === chipId);
|
const i = snap.findIndex((r) => r.id === existingId);
|
||||||
if (i >= 0 && trimmedTitle.length > 0) {
|
if (i >= 0) {
|
||||||
snap[i] = { ...snap[i], label: trimmedTitle };
|
snap[i] = { ...snap[i], label: trimmedTitle };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -512,11 +561,11 @@ export function CoreValuesSelectScreen() {
|
|||||||
coreValuesChipsSnapshot: snap,
|
coreValuesChipsSnapshot: snap,
|
||||||
coreValueDetailsByChipId: {
|
coreValueDetailsByChipId: {
|
||||||
...(prev.coreValueDetailsByChipId ?? {}),
|
...(prev.coreValueDetailsByChipId ?? {}),
|
||||||
[chipId]: nextDetails,
|
[existingId]: nextDetails,
|
||||||
},
|
},
|
||||||
customMethodCardFieldBlocksById: {
|
customMethodCardFieldBlocksById: {
|
||||||
...(prev.customMethodCardFieldBlocksById ?? {}),
|
...(prev.customMethodCardFieldBlocksById ?? {}),
|
||||||
[chipId]: structuredClone(fieldBlocks),
|
[existingId]: structuredClone(fieldBlocks),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -526,13 +575,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
setWizardCustomizeChipId(null);
|
setWizardCustomizeChipId(null);
|
||||||
setWizardInitialValues(null);
|
setWizardInitialValues(null);
|
||||||
},
|
},
|
||||||
[
|
[draft, markCreateFlowInteraction, replaceState, wizardCustomizeChipId],
|
||||||
activeModalChipId,
|
|
||||||
draft,
|
|
||||||
markCreateFlowInteraction,
|
|
||||||
replaceState,
|
|
||||||
wizardCustomizeChipId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const kebabMenuItems = useMemo(() => {
|
const kebabMenuItems = useMemo(() => {
|
||||||
@@ -587,15 +630,14 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const addHandlers = {
|
const addHandlers = {
|
||||||
onAddClick: () => {
|
onAddClick: () => {
|
||||||
|
const selectedCount = coreValueOptions.filter(
|
||||||
|
(o) => o.state === "selected",
|
||||||
|
).length;
|
||||||
|
if (selectedCount >= MAX_CORE_VALUES) return;
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setCoreValueOptions((prev) => {
|
setWizardCustomizeChipId(null);
|
||||||
const next: ChipOption[] = [
|
setWizardInitialValues(null);
|
||||||
...prev,
|
setAddCustomWizardOpen(true);
|
||||||
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
|
||||||
];
|
|
||||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils";
|
import {
|
||||||
|
ASSETS,
|
||||||
|
getAssetPath,
|
||||||
|
governanceBookletPath,
|
||||||
|
structureBeforeCrisisPath,
|
||||||
|
} from "../../../lib/assetUtils";
|
||||||
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../../lib/i18n/getTranslation";
|
||||||
import AboutHeader from "../../components/type/AboutHeader";
|
import AboutHeader from "../../components/type/AboutHeader";
|
||||||
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
|
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
|
||||||
@@ -53,11 +58,20 @@ export default function AboutPage() {
|
|||||||
/>
|
/>
|
||||||
<TripleTextBlock columns={tripleColumns} />
|
<TripleTextBlock columns={tripleColumns} />
|
||||||
<Book
|
<Book
|
||||||
title={page.book.title}
|
title={page.structureBeforeCrisis.title}
|
||||||
description={page.book.description}
|
description={page.structureBeforeCrisis.description}
|
||||||
buttonText={page.book.buttonText}
|
buttonText={page.structureBeforeCrisis.buttonText}
|
||||||
|
buttonHref={getAssetPath(structureBeforeCrisisPath())}
|
||||||
|
imageSrc={getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER)}
|
||||||
|
imageAlt={page.structureBeforeCrisis.imageAlt}
|
||||||
|
/>
|
||||||
|
<Book
|
||||||
|
title={page.communityRules.title}
|
||||||
|
description={page.communityRules.description}
|
||||||
|
buttonText={page.communityRules.buttonText}
|
||||||
buttonHref={getAssetPath(governanceBookletPath())}
|
buttonHref={getAssetPath(governanceBookletPath())}
|
||||||
imageAlt={page.book.imageAlt}
|
imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
|
||||||
|
imageAlt={page.communityRules.imageAlt}
|
||||||
/>
|
/>
|
||||||
<FaqAccordion title={page.faq.title} items={faqItems} />
|
<FaqAccordion title={page.faq.title} items={faqItems} />
|
||||||
<QuoteBlock
|
<QuoteBlock
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function VerticalComponent({
|
|||||||
"data-testid": dataTestId,
|
"data-testid": dataTestId,
|
||||||
}: VerticalProps) {
|
}: VerticalProps) {
|
||||||
const base =
|
const base =
|
||||||
"box-border flex w-[90px] shrink-0 cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
"box-border flex w-full min-w-[90px] cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
|
|||||||
* Figma: "Utility / CardStack"; canonical code under `cards/`.
|
* Figma: "Utility / CardStack"; canonical code under `cards/`.
|
||||||
* Selectable stack of cards with
|
* Selectable stack of cards with
|
||||||
* an optional "see all"/"show less" expand toggle.
|
* an optional "see all"/"show less" expand toggle.
|
||||||
|
* Expanded default layout matches Flow — Expanded Card Stack (`20768:14820`):
|
||||||
|
* 281×142 CardSelection tiles in two columns.
|
||||||
*/
|
*/
|
||||||
const CardStackContainer = memo<CardStackProps>(
|
const CardStackContainer = memo<CardStackProps>(
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface CardStackProps {
|
|||||||
showLessLabel?: string;
|
showLessLabel?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
/** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
|
||||||
layout?: "default" | "singleStack";
|
layout?: "default" | "singleStack";
|
||||||
/**
|
/**
|
||||||
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
|
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup
|
|||||||
import Selection from "../Selection";
|
import Selection from "../Selection";
|
||||||
import type { CardStackViewProps } from "./CardStack.types";
|
import type { CardStackViewProps } from "./CardStack.types";
|
||||||
|
|
||||||
|
/** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
|
||||||
|
const FIGMA_CARD_SELECTION_TILE_CLASS =
|
||||||
|
"h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0";
|
||||||
|
|
||||||
function CardStackHeaderLockup({
|
function CardStackHeaderLockup({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -144,7 +148,7 @@ export function CardStackView({
|
|||||||
recommended={item.recommended ?? false}
|
recommended={item.recommended ?? false}
|
||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
showInfoIcon={true}
|
showInfoIcon={false}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -175,7 +179,8 @@ export function CardStackView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
<>
|
||||||
|
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||||
{cards.map((item) => (
|
{cards.map((item) => (
|
||||||
<Selection
|
<Selection
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -184,15 +189,36 @@ export function CardStackView({
|
|||||||
supportText={item.supportText}
|
supportText={item.supportText}
|
||||||
recommended={item.recommended ?? false}
|
recommended={item.recommended ?? false}
|
||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="vertical"
|
orientation="horizontal"
|
||||||
showInfoIcon={true}
|
showInfoIcon={false}
|
||||||
|
className="min-h-[142px]"
|
||||||
|
onClick={() => onCardSelect(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{addTile}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto hidden w-full max-w-[min(100%,570px)] flex-wrap justify-center gap-2 md:flex">
|
||||||
|
{cards.map((item) => (
|
||||||
|
<Selection
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
label={item.label}
|
||||||
|
supportText={item.supportText}
|
||||||
|
recommended={item.recommended ?? false}
|
||||||
|
selected={isSelected(item.id)}
|
||||||
|
orientation="horizontal"
|
||||||
|
showInfoIcon={false}
|
||||||
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{addTile ? (
|
{addTile ? (
|
||||||
<div className="min-w-0 md:col-span-2">{addTile}</div>
|
<div className="flex w-full min-w-0 shrink-0 justify-center md:flex-[1_1_100%]">
|
||||||
|
{addTile}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : compactDesktopLayout === "pyramidFive" ? (
|
) : compactDesktopLayout === "pyramidFive" ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||||
@@ -230,7 +256,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -249,7 +275,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -268,7 +294,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -284,7 +310,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -299,7 +325,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(compactCards[4].id)}
|
selected={isSelected(compactCards[4].id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(compactCards[4].id)}
|
onClick={() => onCardSelect(compactCards[4].id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,7 +372,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -360,7 +386,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(compactCards[2].id)}
|
selected={isSelected(compactCards[2].id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(compactCards[2].id)}
|
onClick={() => onCardSelect(compactCards[2].id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,7 +405,7 @@ export function CardStackView({
|
|||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
showInfoIcon={false}
|
showInfoIcon={false}
|
||||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
className={FIGMA_CARD_SELECTION_TILE_CLASS}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -431,7 +457,7 @@ export function CardStackView({
|
|||||||
recommended={item.recommended ?? false}
|
recommended={item.recommended ?? false}
|
||||||
selected={isSelected(item.id)}
|
selected={isSelected(item.id)}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
showInfoIcon={true}
|
showInfoIcon={false}
|
||||||
onClick={() => onCardSelect(item.id)}
|
onClick={() => onCardSelect(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { SelectionProps } from "./Selection.types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: "Card / CardSelection" — stacked tile e.g. `16775:28762` (recommended + label + supportText).
|
* Figma: "Card / CardSelection" — stacked tile e.g. `16775:28762` (recommended + label + supportText).
|
||||||
* `orientation="horizontal"` selects that vertical stack; `"vertical"` is label + optional info icon with tag on the right (CardStack expanded / single-column).
|
* `orientation="horizontal"` selects that vertical stack; `"vertical"` is label + tag on the right (CardStack `singleStack` / right-rail). Helper `?` stays off until tooltip behavior ships.
|
||||||
*/
|
*/
|
||||||
const SelectionContainer = memo<SelectionProps>(
|
const SelectionContainer = memo<SelectionProps>(
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface SelectionProps {
|
|||||||
recommended?: boolean;
|
recommended?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
orientation: "horizontal" | "vertical";
|
orientation: "horizontal" | "vertical";
|
||||||
|
/** Off in product until tooltip behavior ships. */
|
||||||
showInfoIcon?: boolean;
|
showInfoIcon?: boolean;
|
||||||
/** Optional id for the root (e.g. `data-card-id` for focus after modal close). */
|
/** Optional id for the root (e.g. `data-card-id` for focus after modal close). */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ function ReadOnlyScopeField({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
<InputLabel label={label} size="s" palette="default" />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{scopes.map((scope) => (
|
{scopes.map((scope) => (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -317,7 +317,7 @@ function ReadOnlyValueField({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<InputLabel label={label} helpIcon size="s" palette="default" />
|
<InputLabel label={label} size="s" palette="default" />
|
||||||
<span className="text-medium-label text-[color:var(--color-content-default-primary)]">
|
<span className="text-medium-label text-[color:var(--color-content-default-primary)]">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { AddCustomFieldProps, AddCustomFieldType } from "./AddCustomField.t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma: "Add Custom Field" control — Community Rule System (`20235:12994`).
|
* Figma: "Add Custom Field" control — Community Rule System (`20235:12994`).
|
||||||
* Collapsed CTA expands to a 2×2 field-type picker (per-type modals deferred).
|
* Collapsed CTA expands to a four-tile field-type picker.
|
||||||
*/
|
*/
|
||||||
const AddCustomFieldContainer = memo<AddCustomFieldProps>(
|
const AddCustomFieldContainer = memo<AddCustomFieldProps>(
|
||||||
({ active, onPressAdd, onSelectFieldType, className = "" }) => {
|
({ active, onPressAdd, onSelectFieldType, className = "" }) => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const ADD_CUSTOM_FIELD_TYPE_ICONS = {
|
|||||||
} as const satisfies Record<AddCustomFieldType, IconName>;
|
} as const satisfies Record<AddCustomFieldType, IconName>;
|
||||||
|
|
||||||
export interface AddCustomFieldProps {
|
export interface AddCustomFieldProps {
|
||||||
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */
|
/** When true, show the four field-type tiles; when false, show the primary CTA. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onPressAdd?: () => void;
|
onPressAdd?: () => void;
|
||||||
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ function FieldTypeButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable block height for collapsed vs expanded so the Create dialog (`top-1/2 -translate-y-1/2`)
|
|
||||||
* does not shrink and re-center when toggling `active`.
|
|
||||||
*
|
|
||||||
* - Collapsed CTA: `py-12` (48+48) + inner row (`py-3` + 20px icon/line) ≈ 140px border-box.
|
|
||||||
* - Expanded: inner `p-4` (32) + Vertical tile (py 12+12, gap 8, 32px icon, 18px label) ≈ 114px — shorter without this floor.
|
|
||||||
*/
|
|
||||||
const ADD_CUSTOM_FIELD_SHELL_MIN_H_PX = 140;
|
|
||||||
|
|
||||||
function AddCustomFieldViewComponent({
|
function AddCustomFieldViewComponent({
|
||||||
active,
|
active,
|
||||||
onPressAdd,
|
onPressAdd,
|
||||||
@@ -55,17 +46,12 @@ function AddCustomFieldViewComponent({
|
|||||||
fieldTypeLabels,
|
fieldTypeLabels,
|
||||||
className,
|
className,
|
||||||
}: AddCustomFieldViewProps) {
|
}: AddCustomFieldViewProps) {
|
||||||
const shellStyle = {
|
|
||||||
minHeight: ADD_CUSTOM_FIELD_SHELL_MIN_H_PX,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPressAdd}
|
onClick={onPressAdd}
|
||||||
style={shellStyle}
|
className={`flex h-[88px] w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 text-medium-label text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
|
||||||
className={`flex w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 py-12 text-medium-label text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
|
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3">
|
<span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3">
|
||||||
<svg
|
<svg
|
||||||
@@ -89,14 +75,13 @@ function AddCustomFieldViewComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedShellClasses = ["flex w-full shrink-0 flex-col", className ?? ""]
|
const expandedShellClasses = ["flex w-full shrink-0", className ?? ""]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={expandedShellClasses} style={shellStyle}>
|
<div className={expandedShellClasses}>
|
||||||
<div className="flex w-full flex-col gap-3 rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] p-4">
|
<div className="grid w-full grid-cols-4 gap-3">
|
||||||
<div className="flex w-full flex-row flex-nowrap justify-center gap-3 overflow-x-auto max-sm:justify-start">
|
|
||||||
<FieldTypeButton
|
<FieldTypeButton
|
||||||
type="text"
|
type="text"
|
||||||
label={fieldTypeLabels.text}
|
label={fieldTypeLabels.text}
|
||||||
@@ -119,7 +104,6 @@ function AddCustomFieldViewComponent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,32 +45,32 @@ 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>
|
||||||
<p className="text-small-label text-[var(--color-content-default-primary,white)] min-w-0">
|
<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 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 flex-col gap-[12px] !space-y-0"
|
className="flex w-full min-w-0 flex-col gap-[12px] !space-y-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function CreateView({
|
|||||||
{headerContent !== undefined ? (
|
{headerContent !== undefined ? (
|
||||||
<div className="shrink-0">{headerContent}</div>
|
<div className="shrink-0">{headerContent}</div>
|
||||||
) : title || description ? (
|
) : title || description ? (
|
||||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
<div className="bg-[var(--color-surface-default-primary)] px-[24px] shrink-0">
|
||||||
<ContentLockup
|
<ContentLockup
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput";
|
|||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
import Alert from "../Alert";
|
import Alert from "../Alert";
|
||||||
import { requestMagicLink } from "../../../../lib/create/api";
|
import { requestMagicLink } from "../../../../lib/create/api";
|
||||||
|
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
|
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
|
||||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||||
import {
|
import {
|
||||||
@@ -232,6 +233,7 @@ export default function LoginForm({
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
error={Boolean(emailError)}
|
error={Boolean(emailError)}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
|
maxLength={EMAIL_MAX_LEN}
|
||||||
/>
|
/>
|
||||||
{emailError ? (
|
{emailError ? (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ function BookView({
|
|||||||
headingId,
|
headingId,
|
||||||
className = "",
|
className = "",
|
||||||
}: BookViewProps) {
|
}: BookViewProps) {
|
||||||
const coverSrc = imageSrc ?? getAssetPath(ASSETS.COMMUNITYRULES_COVER);
|
const coverSrc =
|
||||||
|
imageSrc ?? getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ function ContentLockupView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Link for feature variant */}
|
{/* Link for feature variant */}
|
||||||
{variant === "feature" && linkText && (
|
{variant === "feature" && linkText && linkHref && (
|
||||||
<a
|
<a
|
||||||
href={linkHref || "#"}
|
href={linkHref}
|
||||||
className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5"
|
className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5"
|
||||||
>
|
>
|
||||||
{linkText}
|
{linkText}
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import Login from "../components/modals/Login";
|
import Login from "../components/modals/Login";
|
||||||
import LoginForm from "../components/modals/Login/LoginForm";
|
import LoginForm from "../components/modals/Login/LoginForm";
|
||||||
import { useTranslation } from "./MessagesContext";
|
|
||||||
|
|
||||||
export type AuthModalLoginVariant = "default" | "saveProgress";
|
export type AuthModalLoginVariant = "default" | "saveProgress";
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ const AuthModalContext = createContext<AuthModalContextValue | null>(null);
|
|||||||
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
||||||
const t = useTranslation("pages.login");
|
|
||||||
|
|
||||||
const openLogin = useCallback((o?: OpenLoginOptions) => {
|
const openLogin = useCallback((o?: OpenLoginOptions) => {
|
||||||
setOpts(o ?? {});
|
setOpts(o ?? {});
|
||||||
@@ -62,15 +59,6 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
|||||||
backdropVariant={backdropVariant}
|
backdropVariant={backdropVariant}
|
||||||
usePortal
|
usePortal
|
||||||
ariaLabelledBy="login-modal-heading"
|
ariaLabelledBy="login-modal-heading"
|
||||||
belowCard={
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
|
||||||
onClick={() => closeLogin()}
|
|
||||||
>
|
|
||||||
{t("backToHome")}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
variant={opts.variant ?? "default"}
|
variant={opts.variant ?? "default"}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ stage. Raster → SVG conversion is tracked in
|
|||||||
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
|
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
|
||||||
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
|
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
|
||||||
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
|
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
|
||||||
| `marketing/governance-booklet.pdf` | About / Book | **Done** — PDF (`governanceBookletPath()`) |
|
| `marketing/communityrules-cover.svg` | About / Book | **Done** — Structure Before Crisis cover (`ASSETS.STRUCTURE_BEFORE_CRISIS_COVER`; Figma **22137:891197**) |
|
||||||
|
| `marketing/community-rules-cover.png` | About / Book | **Done** — Community Rules (2021) cover (`ASSETS.COMMUNITY_RULES_COVER`; gitignore exception) |
|
||||||
|
| `marketing/governance-booklet.pdf` | About / Book | **Done** — Community Rules (2021) PDF (`governanceBookletPath()`) |
|
||||||
|
| `marketing/structure-before-crisis.pdf` | About / Book | **Done** — Structure Before Crisis (2025) PDF (`structureBeforeCrisisPath()`) |
|
||||||
| `logos/community-rule.svg` | Logo + favicon (`ASSETS.LOGO`) | **Done** — SVG |
|
| `logos/community-rule.svg` | Logo + favicon (`ASSETS.LOGO`) | **Done** — SVG |
|
||||||
| `share/*.svg` (×5) | Share modal | **Done** — SVG (`shareIconPath()`) |
|
| `share/*.svg` (×5) | Share modal | **Done** — SVG (`shareIconPath()`) |
|
||||||
| `logos/gitlab.svg` | Footer / social | **Done** — SVG |
|
| `logos/gitlab.svg` | Footer / social | **Done** — SVG |
|
||||||
|
|||||||
+11
-3
@@ -97,11 +97,16 @@ export function sectionNumberPath(n: 1 | 2 | 3): string {
|
|||||||
return `assets/marketing/section-number-${n}.svg`;
|
return `assets/marketing/section-number-${n}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Downloadable governance booklet PDF (About / Sections / Book). */
|
/** Community Rules (2021) booklet PDF (About / Sections / Book). */
|
||||||
export function governanceBookletPath(): string {
|
export function governanceBookletPath(): string {
|
||||||
return "assets/marketing/governance-booklet.pdf";
|
return "assets/marketing/governance-booklet.pdf";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Structure Before Crisis (2025) PDF (About / Sections / Book). */
|
||||||
|
export function structureBeforeCrisisPath(): string {
|
||||||
|
return "assets/marketing/structure-before-crisis.pdf";
|
||||||
|
}
|
||||||
|
|
||||||
/** Home feature grid panel art in `public/assets/marketing/`. */
|
/** Home feature grid panel art in `public/assets/marketing/`. */
|
||||||
export type FeaturePanelKey = "support" | "exercises" | "guidance" | "tools";
|
export type FeaturePanelKey = "support" | "exercises" | "guidance" | "tools";
|
||||||
|
|
||||||
@@ -245,8 +250,11 @@ export const ASSETS = {
|
|||||||
/** Quote block default avatar. */
|
/** Quote block default avatar. */
|
||||||
QUOTE_AVATAR: "assets/marketing/quote-avatar.svg",
|
QUOTE_AVATAR: "assets/marketing/quote-avatar.svg",
|
||||||
|
|
||||||
/** Sections / Book cover (Figma **22137:891197**). */
|
/** Structure Before Crisis cover (Figma **22137:891197**; file name is historical). */
|
||||||
COMMUNITYRULES_COVER: "assets/marketing/communityrules-cover.svg",
|
STRUCTURE_BEFORE_CRISIS_COVER: "assets/marketing/communityrules-cover.svg",
|
||||||
|
|
||||||
|
/** Community Rules (2021) cover. */
|
||||||
|
COMMUNITY_RULES_COVER: "assets/marketing/community-rules-cover.png",
|
||||||
|
|
||||||
// Marketing
|
// Marketing
|
||||||
HERO_IMAGE: "assets/marketing/hero-image.png",
|
HERO_IMAGE: "assets/marketing/hero-image.png",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
|
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Max length for the policy description. Wider than the title so Customize can
|
* Max length for persisted method-card `supportText`. Wider than the title so
|
||||||
* seed existing card support text without blocking Next.
|
* Customize can seed existing card copy and catalog blurbs without blocking Next.
|
||||||
*/
|
*/
|
||||||
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
|
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar checkboxes follow the open approach while its modal is up.
|
||||||
|
* With no modal they show key resources assigned to any selected approach,
|
||||||
|
* plus extra reminder checks that are not painted onto chips.
|
||||||
|
*/
|
||||||
|
export function decisionApproachKeyResourceCheckboxIds(params: {
|
||||||
|
detailsById: Record<string, DecisionApproachDetailEntry> | undefined;
|
||||||
|
selectedApproachIds: readonly string[];
|
||||||
|
reminderIds?: readonly string[];
|
||||||
|
openDraft?: DecisionApproachDetailEntry | null;
|
||||||
|
}): string[] {
|
||||||
|
if (params.openDraft) {
|
||||||
|
return decisionApproachKeyResourceIdsFromLabels(
|
||||||
|
params.openDraft.selectedApplicableScope,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assignedLabels: string[] = [];
|
||||||
|
for (const id of params.selectedApproachIds) {
|
||||||
|
const entry = params.detailsById?.[id];
|
||||||
|
if (!entry) continue;
|
||||||
|
assignedLabels.push(...entry.selectedApplicableScope);
|
||||||
|
}
|
||||||
|
return uniquePreserveOrder([
|
||||||
|
...decisionApproachKeyResourceIdsFromLabels(assignedLabels),
|
||||||
|
...(params.reminderIds ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const EMAIL_MAX_LEN = 254;
|
/** RFC 5321 max mailbox length (no angle brackets). */
|
||||||
|
export const EMAIL_MAX_LEN = 254;
|
||||||
|
|
||||||
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
||||||
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Reorders facet-ranked method presets so explicitly confirmed selections pin
|
* Compact CardStack slots pin confirmed selections to the front while the
|
||||||
* to the top while the remainder keeps score-based ranking (recommended before
|
* remainder keeps score-based ranking. Expanded order stays the ranked catalog.
|
||||||
* default).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Selected ids first (selection array order); then tail in `ranked` order. */
|
/** Selected ids first (selection array order); then tail in `ranked` order. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Canonical ordering for method-card facet `selected*Ids` when the user adds a card:
|
* Canonical ordering for method-card facet `selected*Ids` when the user adds a card:
|
||||||
* most recently confirmed id is index 0 so stack / compact layouts stay consistent
|
* most recently confirmed id is index 0 so compact layouts stay consistent
|
||||||
* with {@link orderRankedMethodsWithPinnedSelection}.
|
* with {@link mergeCompactCardIdsWithPinnedSelected}.
|
||||||
*/
|
*/
|
||||||
export function moveFacetSelectionIdToFront(
|
export function moveFacetSelectionIdToFront(
|
||||||
prev: readonly string[],
|
prev: readonly string[],
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||||
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import {
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
||||||
|
} from "../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import { EMAIL_MAX_LEN } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||||
|
|
||||||
@@ -62,8 +67,8 @@ const conflictManagementDetailEntrySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const customMethodCardMetaEntrySchema = z.object({
|
const customMethodCardMetaEntrySchema = z.object({
|
||||||
label: z.string().max(48),
|
label: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS),
|
||||||
supportText: z.string().max(48),
|
supportText: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
||||||
@@ -117,6 +122,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)
|
||||||
@@ -225,7 +231,7 @@ export const putDraftBodySchema = z.object({
|
|||||||
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
|
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
|
||||||
|
|
||||||
export const magicLinkRequestBodySchema = z.object({
|
export const magicLinkRequestBodySchema = z.object({
|
||||||
email: z.string(),
|
email: z.string().max(EMAIL_MAX_LEN),
|
||||||
next: z.string().optional(),
|
next: z.string().optional(),
|
||||||
draft: createFlowStateSchema.optional(),
|
draft: createFlowStateSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_comment": "FeatureGrid component defaults (shared across pages). linkHref is a stub until destination pages ship.",
|
"_comment": "FeatureGrid component defaults (shared across pages). Learn more goes to /learn; feature cards stay non-interactive until destinations are decided.",
|
||||||
"linkText": "Learn more",
|
"linkText": "Learn more",
|
||||||
"linkHref": "#",
|
"linkHref": "/learn",
|
||||||
"ariaLabel": "Feature tools and services",
|
"ariaLabel": "Feature tools and services",
|
||||||
"features": {
|
"features": {
|
||||||
"decisionMaking": {
|
"decisionMaking": {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"dragHandleAriaLabel": "Drag to reorder this field"
|
"dragHandleAriaLabel": "Drag to reorder this field"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"finalize": "Finalize"
|
"finalize": "Finalize policy"
|
||||||
},
|
},
|
||||||
"editModal": {
|
"editModal": {
|
||||||
"noCustomFieldsYet": "No custom fields yet.",
|
"noCustomFieldsYet": "No custom fields yet.",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"clearFileLabel": "Clear file"
|
"clearFileLabel": "Clear file"
|
||||||
},
|
},
|
||||||
"addCustomField": {
|
"addCustomField": {
|
||||||
"cta": "Add",
|
"cta": "Add Custom Field",
|
||||||
"fieldTypes": {
|
"fieldTypes": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"badges": "Badges",
|
"badges": "Badges",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "About page content. Book download href is wired in app/(marketing)/about/page.tsx via governanceBookletPath().",
|
"_comment": "About page content. Book download hrefs are wired in app/(marketing)/about/page.tsx (structureBeforeCrisisPath / governanceBookletPath).",
|
||||||
"aboutHeader": {
|
"aboutHeader": {
|
||||||
"segments": [
|
"segments": [
|
||||||
{ "type": "word", "text": "CommunityRule" },
|
{ "type": "word", "text": "CommunityRule" },
|
||||||
@@ -69,11 +69,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"book": {
|
"structureBeforeCrisis": {
|
||||||
|
"title": "Get Structure Before Crisis",
|
||||||
|
"description": "Structure Before Crisis is a collection of organizing patterns that help mutual aid groups lay the groundwork to build new worlds.",
|
||||||
|
"buttonText": "Download Book",
|
||||||
|
"imageAlt": "Structure Before Crisis book cover"
|
||||||
|
},
|
||||||
|
"communityRules": {
|
||||||
"title": "Get the Community Rules Book",
|
"title": "Get the Community Rules Book",
|
||||||
"description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.",
|
"description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.",
|
||||||
"buttonText": "Download Book",
|
"buttonText": "Download Book",
|
||||||
"imageAlt": "Structure Before Crisis book cover"
|
"imageAlt": "Community Rules book cover"
|
||||||
},
|
},
|
||||||
"faq": {
|
"faq": {
|
||||||
"title": "Get answers to your questions",
|
"title": "Get answers to your questions",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -1,18 +1,6 @@
|
|||||||
<svg width="256" height="370" viewBox="0 0 256 370" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
<svg width="208" height="322" viewBox="24 24 208 321.707" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
<g filter="url(#filter0_d_22851_36281)">
|
|
||||||
<rect x="24" y="24" width="208" height="321.707" rx="4" fill="url(#pattern0_22851_36281)" shape-rendering="crispEdges"/>
|
<rect x="24" y="24" width="208" height="321.707" rx="4" fill="url(#pattern0_22851_36281)" shape-rendering="crispEdges"/>
|
||||||
</g>
|
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="filter0_d_22851_36281" x="0" y="0" width="256" height="369.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
||||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
||||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
|
||||||
<feOffset/>
|
|
||||||
<feGaussianBlur stdDeviation="12"/>
|
|
||||||
<feComposite in2="hardAlpha" operator="out"/>
|
|
||||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
|
||||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_22851_36281"/>
|
|
||||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_22851_36281" result="shape"/>
|
|
||||||
</filter>
|
|
||||||
<pattern id="pattern0_22851_36281" patternContentUnits="objectBoundingBox" width="1" height="1">
|
<pattern id="pattern0_22851_36281" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||||
<use xlink:href="#image0_22851_36281" transform="matrix(0.00252723 0 0 0.00163399 -0.000392154 0)"/>
|
<use xlink:href="#image0_22851_36281" transform="matrix(0.00252723 0 0 0.00163399 -0.000392154 0)"/>
|
||||||
</pattern>
|
</pattern>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 52 KiB |
Binary file not shown.
Binary file not shown.
@@ -97,7 +97,8 @@ export const Expanded = {
|
|||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story: "Expanded list layout with vertical cards and Show less toggle.",
|
story:
|
||||||
|
"Expanded 2-column stack of 142px CardSelection tiles and Show less toggle.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export default {
|
|||||||
},
|
},
|
||||||
showInfoIcon: {
|
showInfoIcon: {
|
||||||
control: { type: "boolean" },
|
control: { type: "boolean" },
|
||||||
description: "Show info icon next to label (typically in vertical)",
|
description:
|
||||||
|
"Optional helper ? next to the label. Off in product until tooltip behavior ships.",
|
||||||
},
|
},
|
||||||
onClick: { action: "clicked" },
|
onClick: { action: "clicked" },
|
||||||
},
|
},
|
||||||
@@ -82,7 +83,7 @@ export const VerticalRecommended = {
|
|||||||
recommended: true,
|
recommended: true,
|
||||||
selected: false,
|
selected: false,
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
showInfoIcon: true,
|
showInfoIcon: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ export const VerticalSelected = {
|
|||||||
recommended: false,
|
recommended: false,
|
||||||
selected: true,
|
selected: true,
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
showInfoIcon: true,
|
showInfoIcon: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ export const AllVariants = {
|
|||||||
recommended={true}
|
recommended={true}
|
||||||
selected={false}
|
selected={false}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
showInfoIcon={true}
|
showInfoIcon={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -147,7 +148,7 @@ export const AllVariants = {
|
|||||||
recommended={false}
|
recommended={false}
|
||||||
selected={true}
|
selected={true}
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
showInfoIcon={true}
|
showInfoIcon={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
"Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` (with help icon) with a `TextArea` set to the `embedded` appearance.",
|
"Shared 'labelled text area' field used by every create-flow modal section. Pairs `InputLabel` with a `TextArea` set to the `embedded` appearance. Section help marks stay off until tooltip behavior ships.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -35,7 +35,7 @@ export const Default = {
|
|||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
label: "Description",
|
label: "Description",
|
||||||
helpIcon: true,
|
helpIcon: false,
|
||||||
placeholder: "What does this rule cover?",
|
placeholder: "What does this rule cover?",
|
||||||
rows: 4,
|
rows: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const HeaderOverlayBlurred = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.',
|
'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, no “Back to home” under the card.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -107,14 +107,6 @@ export const HeaderOverlayBlurred = {
|
|||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
usePortal
|
usePortal
|
||||||
ariaLabelledBy="login-modal-heading"
|
ariaLabelledBy="login-modal-heading"
|
||||||
belowCard={
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="text-center text-small-paragraph text-[var(--color-content-invert-tertiary)] hover:opacity-90"
|
|
||||||
>
|
|
||||||
{backToHome}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading…</p>}>
|
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading…</p>}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import Book from "../../app/components/sections/Book";
|
import Book from "../../app/components/sections/Book";
|
||||||
import { getAssetPath, governanceBookletPath } from "../../lib/assetUtils";
|
import {
|
||||||
|
ASSETS,
|
||||||
|
getAssetPath,
|
||||||
|
governanceBookletPath,
|
||||||
|
structureBeforeCrisisPath,
|
||||||
|
} from "../../lib/assetUtils";
|
||||||
import messages from "../../messages/en/pages/about.json";
|
import messages from "../../messages/en/pages/about.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -11,12 +16,27 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const structureBeforeCrisis = messages.structureBeforeCrisis;
|
||||||
|
const communityRules = messages.communityRules;
|
||||||
|
|
||||||
export const Default = {
|
export const Default = {
|
||||||
args: {
|
args: {
|
||||||
title: messages.book.title,
|
title: structureBeforeCrisis.title,
|
||||||
description: messages.book.description,
|
description: structureBeforeCrisis.description,
|
||||||
buttonText: messages.book.buttonText,
|
buttonText: structureBeforeCrisis.buttonText,
|
||||||
buttonHref: getAssetPath(governanceBookletPath()),
|
buttonHref: getAssetPath(structureBeforeCrisisPath()),
|
||||||
imageAlt: messages.book.imageAlt,
|
imageSrc: getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
|
||||||
|
imageAlt: structureBeforeCrisis.imageAlt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommunityRules = {
|
||||||
|
args: {
|
||||||
|
title: communityRules.title,
|
||||||
|
description: communityRules.description,
|
||||||
|
buttonText: communityRules.buttonText,
|
||||||
|
buttonHref: getAssetPath(governanceBookletPath()),
|
||||||
|
imageSrc: getAssetPath(ASSETS.COMMUNITY_RULES_COVER),
|
||||||
|
imageAlt: communityRules.imageAlt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const FeatureWithLink = {
|
|||||||
"Use our toolkit to improve, document, and evolve your organization.",
|
"Use our toolkit to improve, document, and evolve your organization.",
|
||||||
variant: "feature",
|
variant: "feature",
|
||||||
linkText: "Learn more",
|
linkText: "Learn more",
|
||||||
linkHref: "#",
|
linkHref: "/learn",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -119,4 +119,10 @@ describe("ApplicableScopeField behavior", () => {
|
|||||||
screen.queryByRole("textbox", { name: /Add Applicable Scope/i }),
|
screen.queryByRole("textbox", { name: /Add Applicable Scope/i }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render a section help icon", () => {
|
||||||
|
renderWithProviders(<ApplicableScopeField {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument();
|
||||||
const textboxes = within(dialog).getAllByRole("textbox");
|
const textboxes = within(dialog).getAllByRole("textbox");
|
||||||
expect(textboxes.length).toBe(3);
|
expect(textboxes.length).toBe(3);
|
||||||
const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
|
const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
|
||||||
@@ -160,6 +161,44 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
|||||||
expect(textareas[2].value).toBe("Saved coc");
|
expect(textareas[2].value).toBe("Saved coc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists edits and closes when Save is clicked on an already-selected platform", async () => {
|
||||||
|
let latest: CreateFlowState = {};
|
||||||
|
render(
|
||||||
|
<ScreenWithStateProbe
|
||||||
|
onState={(s) => {
|
||||||
|
latest = s;
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
selectedCommunicationMethodIds: ["signal"],
|
||||||
|
communicationMethodDetailsById: {
|
||||||
|
signal: {
|
||||||
|
corePrinciple: "Saved principle",
|
||||||
|
logisticsAdmin: "Saved logistics",
|
||||||
|
codeOfConduct: "Saved coc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
const textareas = within(dialog).getAllByRole(
|
||||||
|
"textbox",
|
||||||
|
) as HTMLTextAreaElement[];
|
||||||
|
fireEvent.change(textareas[0], { target: { value: "Edited principle" } });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||||
|
).toBe("Edited principle");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps catalog section editors when a title override exists and there are no custom fields", async () => {
|
it("keeps catalog section editors when a title override exists and there are no custom fields", async () => {
|
||||||
const details = communicationPresetFor("video-meetings");
|
const details = communicationPresetFor("video-meetings");
|
||||||
const noFieldsHint =
|
const noFieldsHint =
|
||||||
@@ -403,7 +442,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Core Principle & Scope" }),
|
screen.getByRole("button", { name: "Core Principle & Scope" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
|
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
|
||||||
@@ -454,7 +493,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
||||||
@@ -507,7 +546,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
|||||||
fireEvent.dragStart(rows[0], { dataTransfer });
|
fireEvent.dragStart(rows[0], { dataTransfer });
|
||||||
fireEvent.drop(rows[2], { dataTransfer });
|
fireEvent.drop(rows[2], { dataTransfer });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ describe("CoreValuesSelectScreen", () => {
|
|||||||
fireEvent.dragStart(rows[0], { dataTransfer });
|
fireEvent.dragStart(rows[0], { dataTransfer });
|
||||||
fireEvent.drop(rows[1], { dataTransfer });
|
fireEvent.drop(rows[1], { dataTransfer });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
@@ -256,25 +256,12 @@ describe("CoreValuesSelectScreen", () => {
|
|||||||
expect(labels[1]).toMatch(/What does this value mean to your group/);
|
expect(labels[1]).toMatch(/What does this value mean to your group/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The "Add value" → custom-chip → modal flow uses a `customPending`
|
// The "Add value" control opens an empty custom-policy wizard. Dismissing
|
||||||
// session: dismissing the modal must drop the brand-new chip entirely
|
// without Finalize leaves the chip list unchanged. Finalize writes a
|
||||||
// (not just unselect it), because the user never confirmed it via
|
// selected chip named from the wizard title.
|
||||||
// the modal's Add Value button. Clicking Add Value keeps the chip
|
describe("Add value wizard", () => {
|
||||||
// as a selected entry. These two tests pin both halves of the
|
|
||||||
// contract so the screens stay in sync with the create-flow draft.
|
|
||||||
describe("custom chip — confirmed vs dismissed", () => {
|
|
||||||
// Use a label guaranteed to NOT collide with any preset value
|
|
||||||
// (we'd otherwise get two matching chips and false positives).
|
|
||||||
const CUSTOM_LABEL = "ZZTopBespokeValue";
|
const CUSTOM_LABEL = "ZZTopBespokeValue";
|
||||||
|
|
||||||
async function addCustomChipNamed(label: string) {
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
|
||||||
const input = await screen.findByPlaceholderText("Type to add");
|
|
||||||
fireEvent.change(input, { target: { value: label } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
||||||
return screen.findByRole("dialog");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The label can also appear in the modal header while the modal
|
* The label can also appear in the modal header while the modal
|
||||||
* is open, and as the chip's "Remove" button aria-label. Scope to
|
* is open, and as the chip's "Remove" button aria-label. Scope to
|
||||||
@@ -290,33 +277,94 @@ describe("CoreValuesSelectScreen", () => {
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("removes the custom chip when its modal is dismissed without Add Value", async () => {
|
async function completeAddValueWizard(label: string) {
|
||||||
renderWithProviders(<CoreValuesSelectScreen />);
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
await addCustomChipNamed(CUSTOM_LABEL);
|
const nameInput = await screen.findByPlaceholderText("Policy name");
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
fireEvent.change(nameInput, { target: { value: label } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
const descriptionInput =
|
||||||
|
await screen.findByPlaceholderText("Policy description");
|
||||||
|
fireEvent.change(descriptionInput, {
|
||||||
|
target: { value: "A community-authored value." },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Custom policy details"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add Custom Field" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Finalize policy" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("collapses the field-type picker after adding a custom field", async () => {
|
||||||
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
|
fireEvent.change(await screen.findByPlaceholderText("Policy name"), {
|
||||||
|
target: { value: CUSTOM_LABEL },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
fireEvent.change(
|
||||||
|
await screen.findByPlaceholderText("Policy description"),
|
||||||
|
{ target: { value: "A community-authored value." } },
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Custom policy details"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const wizard = screen.getByRole("dialog");
|
||||||
|
fireEvent.click(
|
||||||
|
within(wizard).getByRole("button", { name: "Add Custom Field" }),
|
||||||
|
);
|
||||||
|
fireEvent.click(within(wizard).getByRole("button", { name: "Text" }));
|
||||||
|
fireEvent.change(
|
||||||
|
await screen.findByPlaceholderText("Add your text block title"),
|
||||||
|
{ target: { value: "Policy title" } },
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add field" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(wizard).findByRole("button", { name: "Add Custom Field" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(wizard).queryByRole("button", { name: "Text" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(wizard).getByRole("button", { name: "Policy title" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the empty custom-policy wizard from Add value", async () => {
|
||||||
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByPlaceholderText("Policy name"),
|
||||||
|
).toHaveValue("");
|
||||||
|
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no chip when the Add value wizard is dismissed", async () => {
|
||||||
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
|
||||||
|
await screen.findByPlaceholderText("Policy name");
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByPlaceholderText("Policy name"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// Chip must be gone — not just unselected. If it were merely
|
|
||||||
// unselected the chip button would still render. Wrap in waitFor
|
|
||||||
// because the chip removal flushes through `updateState` →
|
|
||||||
// `useEffect` → `setCoreValueOptions` and isn't synchronous with
|
|
||||||
// the dialog close.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
|
expect(countCustomChips(CUSTOM_LABEL)).toBe(0);
|
||||||
});
|
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the custom chip selected when Add Value is clicked", async () => {
|
it("adds a selected chip named from the wizard title after Finalize", async () => {
|
||||||
renderWithProviders(<CoreValuesSelectScreen />);
|
renderWithProviders(<CoreValuesSelectScreen />);
|
||||||
const dialog = await addCustomChipNamed(CUSTOM_LABEL);
|
await completeAddValueWizard(CUSTOM_LABEL);
|
||||||
fireEvent.click(
|
|
||||||
within(dialog).getByRole("button", { name: "Add Value" }),
|
|
||||||
);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByPlaceholderText("Policy name"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe("CustomMethodCardFieldBlocksSummary", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("after remove, parent can pass cleared blocks and Upload shows again", () => {
|
it("after remove, parent can pass cleared blocks and Upload shows again", () => {
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ describe("FeatureGrid (behavioral tests)", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends Learn more to the Learn page", () => {
|
||||||
|
render(<FeatureGrid title="Test" subtitle="Test" />);
|
||||||
|
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/learn",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not apply a focus ring to the entire grid shell", () => {
|
it("does not apply a focus ring to the entire grid shell", () => {
|
||||||
render(<FeatureGrid title="Test" subtitle="Test" />);
|
render(<FeatureGrid title="Test" subtitle="Test" />);
|
||||||
const shell = document.querySelector('[data-figma-node="18847-22410"]');
|
const shell = document.querySelector('[data-figma-node="18847-22410"]');
|
||||||
|
|||||||
@@ -438,6 +438,86 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes a values chip without discard when nothing changed", async () => {
|
||||||
|
render(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
onState={() => {
|
||||||
|
/* noop */
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
selectedCoreValueIds: ["1"],
|
||||||
|
coreValuesChipsSnapshot: [
|
||||||
|
{ id: "1", label: "Accessibility", state: "selected" },
|
||||||
|
],
|
||||||
|
coreValueDetailsByChipId: {
|
||||||
|
"1": {
|
||||||
|
meaning: "Everyone can participate.",
|
||||||
|
signals: "Captions and ramps.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customMethodCardFieldBlocksById: {
|
||||||
|
"1": [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
id: "facet-meaning",
|
||||||
|
blockTitle: "What this value means",
|
||||||
|
placeholderText: "Everyone can participate.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
id: "facet-signals",
|
||||||
|
blockTitle: "Signals",
|
||||||
|
placeholderText: "Captions and ramps.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Accessibility" }),
|
||||||
|
);
|
||||||
|
const valuesDialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(valuesDialog).getAllByRole("textbox").length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Keep editing" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a method chip without discard when nothing changed", async () => {
|
||||||
|
render(
|
||||||
|
<FinalReviewWithStateProbe
|
||||||
|
onState={() => {
|
||||||
|
/* noop */
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
title: "Oak Park Commons",
|
||||||
|
selectedCommunicationMethodIds: ["signal"],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||||
|
const methodDialog = await screen.findByRole("dialog");
|
||||||
|
expect(within(methodDialog).getAllByRole("textbox").length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Keep editing" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -749,7 +829,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
|||||||
fireEvent.change(nameInput, { target: { value: "Custom Signal header" } });
|
fireEvent.change(nameInput, { target: { value: "Custom Signal header" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
||||||
@@ -803,7 +883,7 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
|||||||
fireEvent.dragStart(rows[0], { dataTransfer });
|
fireEvent.dragStart(rows[0], { dataTransfer });
|
||||||
fireEvent.drop(rows[2], { dataTransfer });
|
fireEvent.drop(rows[2], { dataTransfer });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
|
fireEvent.click(screen.getByRole("button", { name: "Finalize policy" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOrigi
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { EMAIL_MAX_LEN } from "../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import { requestMagicLink } from "../../lib/create/api";
|
import { requestMagicLink } from "../../lib/create/api";
|
||||||
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ describe("LoginForm", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("textbox", { name: /email address/i }),
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
).toBeInTheDocument();
|
).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -126,6 +127,24 @@ describe("LoginForm", () => {
|
|||||||
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
|
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits a long email without treating length as invalid", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const email =
|
||||||
|
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
|
||||||
|
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||||
|
renderLoginForm();
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestMagicLink).toHaveBeenCalledWith(email, "/", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
|
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||||
|
|||||||
@@ -85,4 +85,29 @@ describe("ModalTextAreaField behavior", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled();
|
expect(screen.getByRole("textbox", { name: /Notes/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render a section help icon by default", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ModalTextAreaField
|
||||||
|
label="Core principle"
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a section help icon when helpIcon is true", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ModalTextAreaField
|
||||||
|
label="Core principle"
|
||||||
|
helpIcon
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByAltText("Help")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ describe("AuthModalProvider (header overlay)", () => {
|
|||||||
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /back to home/i }),
|
screen.queryByRole("link", { name: /back to home/i }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes overlay when closeLogin is called", async () => {
|
it("closes overlay when closeLogin is called", async () => {
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ test.describe("Critical User Journeys", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
featureSection.locator('a[href="#decision-making"]'),
|
featureSection.locator('a[href="#decision-making"]'),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(featureSection.getByRole("link", { name: "Learn more" })).toBeVisible();
|
await expect(featureSection.getByRole("link", { name: "Learn more" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/learn",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("header navigation functionality", async ({ page }) => {
|
test("header navigation functionality", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { within } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
} from "../utils/test-utils";
|
||||||
|
import AboutPage from "../../app/(marketing)/about/page";
|
||||||
|
import {
|
||||||
|
ASSETS,
|
||||||
|
getAssetPath,
|
||||||
|
governanceBookletPath,
|
||||||
|
structureBeforeCrisisPath,
|
||||||
|
} from "../../lib/assetUtils";
|
||||||
|
import messages from "../../messages/en/index";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function bookSection(title: string) {
|
||||||
|
const heading = screen.getByRole("heading", { name: title });
|
||||||
|
const section = heading.closest("section");
|
||||||
|
expect(section).not.toBeNull();
|
||||||
|
return within(section as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AboutPage", () => {
|
||||||
|
test("pairs each book cover, heading, alt, and download with the same title", () => {
|
||||||
|
render(<AboutPage />);
|
||||||
|
const page = messages.pages.about;
|
||||||
|
|
||||||
|
const structureBeforeCrisis = bookSection(page.structureBeforeCrisis.title);
|
||||||
|
expect(
|
||||||
|
structureBeforeCrisis.getByText(page.structureBeforeCrisis.description),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
structureBeforeCrisis.getByRole("img", {
|
||||||
|
name: page.structureBeforeCrisis.imageAlt,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
structureBeforeCrisis.getByRole("link", {
|
||||||
|
name: page.structureBeforeCrisis.buttonText,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", getAssetPath(structureBeforeCrisisPath()));
|
||||||
|
|
||||||
|
const communityRules = bookSection(page.communityRules.title);
|
||||||
|
expect(
|
||||||
|
communityRules.getByText(page.communityRules.description),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
communityRules.getByRole("img", {
|
||||||
|
name: page.communityRules.imageAlt,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("src", getAssetPath(ASSETS.COMMUNITY_RULES_COVER));
|
||||||
|
expect(
|
||||||
|
communityRules.getByRole("link", {
|
||||||
|
name: page.communityRules.buttonText,
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", getAssetPath(governanceBookletPath()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
cleanup,
|
cleanup,
|
||||||
within,
|
within,
|
||||||
|
waitFor,
|
||||||
} from "../utils/test-utils";
|
} from "../utils/test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, test, expect, afterEach } from "vitest";
|
import { describe, test, expect, afterEach } from "vitest";
|
||||||
@@ -161,6 +162,45 @@ describe("Create flow decision-approaches page", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selecting an approach in the expanded list does not move it to the top", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DecisionApproachesScreen />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "See all decision approaches" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardLabels = () =>
|
||||||
|
screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((el) => el.getAttribute("aria-label") || "")
|
||||||
|
.filter((label) => label.includes(": "));
|
||||||
|
|
||||||
|
const labelsBefore = cardLabels();
|
||||||
|
expect(labelsBefore[0]).toMatch(/^Lazy Consensus:/);
|
||||||
|
expect(labelsBefore.some((label) => label.startsWith("Sociocracy:"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /^Sociocracy:/ }));
|
||||||
|
await user.click(
|
||||||
|
within(await screen.findByRole("dialog")).getByRole("button", {
|
||||||
|
name: "Add Approach",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Show less" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(cardLabels()).toEqual(labelsBefore);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /^Sociocracy:/ }),
|
||||||
|
).toHaveTextContent("SELECTED");
|
||||||
|
});
|
||||||
|
|
||||||
test("clicking a card opens the create modal and confirming selects it", async () => {
|
test("clicking a card opens the create modal and confirming selects it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<DecisionApproachesScreen />);
|
render(<DecisionApproachesScreen />);
|
||||||
@@ -212,6 +252,33 @@ describe("Create flow decision-approaches page", () => {
|
|||||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Save on a selected approach persists the edit and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
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");
|
||||||
|
await user.click(
|
||||||
|
within(dialog).getByRole("button", { name: "Add Approach" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(card);
|
||||||
|
const dialogAgain = await screen.findByRole("dialog");
|
||||||
|
const principleField = within(dialogAgain).getByRole("textbox", {
|
||||||
|
name: /core principle/i,
|
||||||
|
});
|
||||||
|
await user.clear(principleField);
|
||||||
|
await user.type(principleField, "Edited principle");
|
||||||
|
await user.click(within(dialogAgain).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Remove from the kebab deselects the approach", async () => {
|
test("Remove from the kebab deselects the approach", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<DecisionApproachesScreen />);
|
render(<DecisionApproachesScreen />);
|
||||||
@@ -272,4 +339,280 @@ 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 does not highlight that chip on an unselected 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: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applicable-scope chips stay on the approach they were chosen for", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DecisionApproachesScreen />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyDialog = await screen.findByRole("dialog");
|
||||||
|
await user.click(
|
||||||
|
within(lazyDialog).getByRole("button", {
|
||||||
|
name: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).toBeChecked();
|
||||||
|
await user.click(
|
||||||
|
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(doocracyDialog).queryByRole("button", {
|
||||||
|
name: "Deselect Steward finances",
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).not.toBeChecked();
|
||||||
|
await user.click(
|
||||||
|
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyAgain = await screen.findByRole("dialog");
|
||||||
|
expect(
|
||||||
|
within(lazyAgain).getByRole("button", {
|
||||||
|
name: "Deselect Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checking a key-resource box with a modal open selects that chip on the open approach only", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DecisionApproachesScreen />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyDialog = await screen.findByRole("dialog");
|
||||||
|
await user.click(
|
||||||
|
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Do-ocracy: Decisions are made by those who take initiative/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const doocracyDialog = await screen.findByRole("dialog");
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(doocracyDialog).getByRole("button", {
|
||||||
|
name: "Deselect Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
within(doocracyDialog).getByRole("button", { name: "Close dialog" }),
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Discard" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyAgain = await screen.findByRole("dialog");
|
||||||
|
expect(
|
||||||
|
within(lazyAgain).getByRole("button", {
|
||||||
|
name: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("highlighting a key-resource chip on a second approach does not rewrite the first", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<DecisionApproachesScreen />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyDialog = await screen.findByRole("dialog");
|
||||||
|
await user.click(
|
||||||
|
within(lazyDialog).getByRole("button", {
|
||||||
|
name: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).toBeChecked();
|
||||||
|
await user.click(
|
||||||
|
within(lazyDialog).getByRole("button", { name: "Add Approach" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Do-ocracy: Decisions are made by those who take initiative/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const doocracyDialog = await screen.findByRole("dialog");
|
||||||
|
await user.click(
|
||||||
|
within(doocracyDialog).getByRole("button", {
|
||||||
|
name: "Select Project level decisions",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Project level decisions" }),
|
||||||
|
).toBeChecked();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).not.toBeChecked();
|
||||||
|
expect(
|
||||||
|
within(doocracyDialog).getByRole("button", {
|
||||||
|
name: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
within(doocracyDialog).getByRole("button", { name: "Add Approach" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Steward finances" }),
|
||||||
|
).toBeChecked();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("checkbox", { name: "Project level decisions" }),
|
||||||
|
).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Lazy Consensus: A decision is assumed approved/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lazyAgain = await screen.findByRole("dialog");
|
||||||
|
expect(
|
||||||
|
within(lazyAgain).getByRole("button", {
|
||||||
|
name: "Deselect Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(lazyAgain).getByRole("button", {
|
||||||
|
name: "Select Project level decisions",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
within(lazyAgain).getByRole("button", { name: "Close dialog" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Do-ocracy: Decisions are made by those who take initiative/,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const doocracyAgain = await screen.findByRole("dialog");
|
||||||
|
expect(
|
||||||
|
within(doocracyAgain).getByRole("button", {
|
||||||
|
name: "Deselect Project level decisions",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(doocracyAgain).getByRole("button", {
|
||||||
|
name: "Select Steward finances",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ describe("Page", () => {
|
|||||||
"Use our toolkit to improve, document, and evolve your organization.",
|
"Use our toolkit to improve, document, and evolve your organization.",
|
||||||
).length,
|
).length,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
const learnMore = screen.getAllByRole("link", { name: "Learn more" });
|
||||||
|
expect(learnMore[0]).toHaveAttribute("href", "/learn");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders ask organizer section with correct data", () => {
|
test("renders ask organizer section with correct data", () => {
|
||||||
|
|||||||
@@ -60,9 +60,38 @@ describe("CardStack Component", () => {
|
|||||||
test("renders all cards in expanded (list) mode", () => {
|
test("renders all cards in expanded (list) mode", () => {
|
||||||
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
|
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
|
||||||
|
|
||||||
expect(screen.getByText("Option A")).toBeInTheDocument();
|
expect(screen.getAllByText("Option A").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("Option B")).toBeInTheDocument();
|
expect(screen.getAllByText("Option B").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("Option C")).toBeInTheDocument();
|
expect(screen.getAllByText("Option C").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides helper icons on compact, expanded, and single-stack cards", () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<CardStack cards={SAMPLE_CARDS} expanded={false} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("?")).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const expanded = render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
|
||||||
|
expect(screen.queryByText("?")).not.toBeInTheDocument();
|
||||||
|
expanded.unmount();
|
||||||
|
|
||||||
|
render(<CardStack cards={SAMPLE_CARDS} layout="singleStack" />);
|
||||||
|
expect(screen.queryByText("?")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded tiles use the compact 142px CardSelection height", () => {
|
||||||
|
render(<CardStack cards={SAMPLE_CARDS} expanded={true} />);
|
||||||
|
|
||||||
|
const tiles = screen.getAllByRole("button", {
|
||||||
|
name: "Option A: Description A",
|
||||||
|
});
|
||||||
|
expect(tiles.length).toBeGreaterThan(0);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
expect(tile.className).toMatch(/142px/);
|
||||||
|
expect(tile).toHaveClass("flex-col");
|
||||||
|
expect(tile).not.toHaveClass("flex-row");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows See all toggle when hasMore is true", () => {
|
test("shows See all toggle when hasMore is true", () => {
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ describe("Selection Component", () => {
|
|||||||
expect(card).toHaveClass("flex-row");
|
expect(card).toHaveClass("flex-row");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not render a helper icon by default", () => {
|
||||||
|
render(<Selection {...defaultProps} orientation="vertical" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("?")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a helper icon when showInfoIcon is true", () => {
|
||||||
|
render(
|
||||||
|
<Selection
|
||||||
|
{...defaultProps}
|
||||||
|
orientation="vertical"
|
||||||
|
showInfoIcon={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("?")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("handles click events", () => {
|
it("handles click events", () => {
|
||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
render(<Selection {...defaultProps} onClick={handleClick} />);
|
render(<Selection {...defaultProps} onClick={handleClick} />);
|
||||||
|
|||||||
@@ -113,6 +113,63 @@ describe("POST /api/auth/magic-link/request", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts an email longer than 48 characters", async () => {
|
||||||
|
const email =
|
||||||
|
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
|
||||||
|
expect(email.length).toBeGreaterThan(48);
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sendMagicLinkEmailMock).toHaveBeenCalledWith(
|
||||||
|
email,
|
||||||
|
expect.stringContaining("/api/auth/magic-link/verify?token="),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an email longer than 254 characters", async () => {
|
||||||
|
const email = `${"a".repeat(243)}@example.com`;
|
||||||
|
expect(email.length).toBeGreaterThan(254);
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a draft whose method-card support text is longer than 48 characters", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new NextRequest("https://x.test/api/auth/magic-link/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "a@b.c",
|
||||||
|
draft: {
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
"00000000-0000-4000-8000-000000000001": {
|
||||||
|
label: "Signal",
|
||||||
|
supportText:
|
||||||
|
"A decision is assumed approved unless objections are raised within a specified timeframe.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 502 and rolls back the token when mail fails", async () => {
|
it("returns 502 and rolls back the token when mail fails", async () => {
|
||||||
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS } from "../../lib/create/customMethodCardWizardConstants";
|
||||||
import {
|
import {
|
||||||
assertPlainJsonValue,
|
assertPlainJsonValue,
|
||||||
DEFAULT_PLAIN_JSON_LIMITS,
|
DEFAULT_PLAIN_JSON_LIMITS,
|
||||||
@@ -87,6 +88,33 @@ describe("createFlowStateSchema", () => {
|
|||||||
expect(r.success).toBe(false);
|
expect(r.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts custom method card support text longer than 48 characters", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
"00000000-0000-4000-8000-000000000001": {
|
||||||
|
label: "Signal",
|
||||||
|
supportText:
|
||||||
|
"A decision is assumed approved unless objections are raised within a specified timeframe.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects custom method card support text over the description max", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
customMethodCardMetaById: {
|
||||||
|
"00000000-0000-4000-8000-000000000001": {
|
||||||
|
label: "Signal",
|
||||||
|
supportText: "x".repeat(
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS + 1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts communityStructureChipSnapshots with custom chip rows", () => {
|
it("accepts communityStructureChipSnapshots with custom chip rows", () => {
|
||||||
const r = createFlowStateSchema.safeParse({
|
const r = createFlowStateSchema.safeParse({
|
||||||
communityStructureChipSnapshots: {
|
communityStructureChipSnapshots: {
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyDecisionApproachKeyResources,
|
||||||
|
decisionApproachKeyResourceCheckboxIds,
|
||||||
|
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("derives sidebar checks from the open draft or selected approaches", () => {
|
||||||
|
const detailsById = {
|
||||||
|
"lazy-consensus": {
|
||||||
|
...emptyEntry(),
|
||||||
|
selectedApplicableScope: ["Steward finances"],
|
||||||
|
},
|
||||||
|
"do-ocracy": {
|
||||||
|
...emptyEntry(),
|
||||||
|
selectedApplicableScope: ["Project level decisions"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
decisionApproachKeyResourceCheckboxIds({
|
||||||
|
detailsById,
|
||||||
|
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
|
||||||
|
reminderIds: ["discipline"],
|
||||||
|
}),
|
||||||
|
).toEqual(["finances", "project", "discipline"]);
|
||||||
|
expect(
|
||||||
|
decisionApproachKeyResourceCheckboxIds({
|
||||||
|
detailsById,
|
||||||
|
selectedApproachIds: ["lazy-consensus", "do-ocracy"],
|
||||||
|
reminderIds: ["discipline"],
|
||||||
|
openDraft: {
|
||||||
|
...emptyEntry(),
|
||||||
|
selectedApplicableScope: ["Amend your CommunityRule"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual(["amend"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,18 +1,21 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getAssetPath, governanceBookletPath } from "../../../lib/assetUtils";
|
import {
|
||||||
|
governanceBookletPath,
|
||||||
|
structureBeforeCrisisPath,
|
||||||
|
} from "../../../lib/assetUtils";
|
||||||
|
|
||||||
describe("governance booklet", () => {
|
const publicRoot = join(process.cwd(), "public");
|
||||||
it("ships a PDF at the About Book download path", () => {
|
|
||||||
const relative = governanceBookletPath();
|
|
||||||
expect(relative).toBe("assets/marketing/governance-booklet.pdf");
|
|
||||||
expect(getAssetPath(relative)).toBe("/assets/marketing/governance-booklet.pdf");
|
|
||||||
|
|
||||||
const abs = join(process.cwd(), "public", relative);
|
function assertPdf(relativePath: string) {
|
||||||
expect(existsSync(abs)).toBe(true);
|
const bytes = readFileSync(join(publicRoot, relativePath));
|
||||||
|
expect(bytes.subarray(0, 5).toString("ascii")).toBe("%PDF-");
|
||||||
|
}
|
||||||
|
|
||||||
const header = readFileSync(abs).subarray(0, 5).toString("latin1");
|
describe("marketing book PDFs", () => {
|
||||||
expect(header).toBe("%PDF-");
|
it("ships both About book downloads as PDFs", () => {
|
||||||
|
assertPdf(governanceBookletPath());
|
||||||
|
assertPdf(structureBeforeCrisisPath());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user