Files
community-rule/app/(app)/create/screens/review/FinalReviewScreen.tsx
T

336 lines
12 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import Rule, { type Category } from "../../../../components/cards/Rule";
import Alert from "../../../../components/modals/Alert";
import { TemplateChipDetailModal } from "../../../../components/cards/TemplateReviewCard/TemplateChipDetailModal";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CreateFlowState } from "../../types";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
} from "../../components/CreateFlowLockupCardStepShell";
import {
buildFinalReviewCategoryRowsDetailed,
type FinalReviewCategoryNames,
type FinalReviewCategoryRowDetailed,
} from "../../../../../lib/create/buildFinalReviewCategories";
import { communityInitialsFromTitle } from "../../../../../lib/create/communityInitials";
import { finalReviewChipHasWarning } from "../../../../../lib/create/finalReviewChipWarnings";
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
import type {
TemplateChipDetail,
TemplateFacetGroupKey,
} from "../../../../../lib/create/templateReviewMapping";
import {
FinalReviewChipEditModal,
type FinalReviewChipEditPatch,
type FinalReviewChipEditTarget,
} from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
import { isPreviewSurfaceColor } from "../../../../../lib/create/previewSurfaceColor";
import {
persistDraftPreviewSurfaceColor,
readDraftPreviewSurfaceColor,
} from "../../utils/resolvePreviewSurfaceFallback";
function bindingArbitrationNeedsSeparateYes(state: CreateFlowState): boolean {
const membership = state.selectedMembershipMethodIds ?? [];
const conflict = state.selectedConflictManagementIds ?? [];
const joinsWithoutAYes =
membership.includes("open-admission") ||
membership.includes("membership-by-token");
return joinsWithoutAYes && conflict.includes("binding-arbitration");
}
function categoryNamesFromMessages(
categories: readonly { name: string }[],
): FinalReviewCategoryNames {
const get = (i: number): string =>
typeof categories[i]?.name === "string" ? categories[i].name : "";
return {
values: get(0),
communication: get(1),
membership: get(2),
decisions: get(3),
conflict: get(4),
};
}
export function FinalReviewScreen({
variant = "default",
}: {
variant?: "default" | "editPublished";
} = {}) {
const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow();
const { goToStep } = useCreateFlowNavigation();
const mdUp = useCreateFlowMdUp();
const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
/**
* Two modals coexist on this screen:
*
* - {@link FinalReviewChipEditModal} — core values + method chips: footer
* **Remove** when the chip is already selected, plus kebab **Remove**;
* values also offer Duplicate under the five-chip cap; method chips
* offer Customize (prefilled custom-policy wizard). Fields are editable on
* open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* also writes `customMethodCardMetaById`.
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
* can't map to an override key (e.g. template body entries on the
* "Use without changes" path where no preset matches the title).
*
* `activeEditTarget` drives the editable modal; `activeReadOnlyDetail`
* drives the read-only modal; only one is ever non-null at a time.
*/
const [activeEditTarget, setActiveEditTarget] =
useState<FinalReviewChipEditTarget | null>(null);
const [activeReadOnlyDetail, setActiveReadOnlyDetail] =
useState<TemplateChipDetail | null>(null);
const [communityContextModalOpen, setCommunityContextModalOpen] =
useState(false);
const [titleModalOpen, setTitleModalOpen] = useState(false);
const handleSave = useCallback(
(patch: FinalReviewChipEditPatch) => {
markCreateFlowInteraction();
updateState(applyFinalReviewChipEditPatch(state, patch));
},
[markCreateFlowInteraction, updateState, state],
);
const { categories: finalReviewCategories } = useMemo(() => {
const copy = m.create.reviewAndComplete.finalReview;
const names = categoryNamesFromMessages(copy.categories);
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
buildFinalReviewCategoryRowsDetailed(state, names);
const lookup = new Map<
string,
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
>();
const cats: Category[] = rowsToRender.map((row) => {
const effectiveGroupKey: TemplateFacetGroupKey | null = row.groupKey;
const reviewReturn =
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
const isEmpty = row.entries.length === 0;
const flag =
isEmpty && effectiveGroupKey === "coreValues"
? copy.flags.noValues
: isEmpty && effectiveGroupKey != null
? copy.flags.noCard
: undefined;
const addButtonText = isEmpty
? effectiveGroupKey === "coreValues"
? copy.addValue
: copy.addCard
: "";
const chipOptions = row.entries.map((entry, idx) => {
const chipId = `${row.name}-${idx}`;
const readOnly: TemplateChipDetail = {
chipId,
chipLabel: entry.label,
categoryName: row.name,
groupKey: entry.groupKey,
body: "",
};
const target: FinalReviewChipEditTarget | null =
entry.groupKey && entry.overrideKey
? {
overrideKey: entry.overrideKey,
groupKey: entry.groupKey,
chipLabel: entry.label,
}
: null;
lookup.set(chipId, { target, readOnly });
return {
id: chipId,
label: entry.label,
state: "unselected" as const,
hasWarning: finalReviewChipHasWarning(
state,
entry.groupKey,
entry.overrideKey,
),
};
});
return {
name: row.name,
chipOptions,
addButton: effectiveGroupKey != null,
addButtonText,
flag,
onChipClick: (_categoryName: string, chipId: string) => {
const hit = lookup.get(chipId);
if (!hit) return;
markCreateFlowInteraction();
if (hit.target) {
setActiveEditTarget(hit.target);
} else {
setActiveReadOnlyDetail(hit.readOnly);
}
},
onAddClick:
effectiveGroupKey != null
? () => {
markCreateFlowInteraction();
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
reviewReturn,
});
}
: undefined,
};
});
return { categories: cats };
}, [
m.create.reviewAndComplete.finalReview,
state,
markCreateFlowInteraction,
goToStep,
variant,
]);
const ruleCardTitle = useMemo(() => {
const raw = typeof state.title === "string" ? state.title.trim() : "";
return raw.length > 0 ? raw : t("ruleCardTitleFallback");
}, [state.title, t]);
/**
* Match {@link CommunityReviewScreen}: the card body is the free-text
* `community-context` field only — not `summary`.
*/
const ruleCardDescription = useMemo(() => {
const raw =
typeof state.communityContext === "string"
? state.communityContext.trim()
: "";
return raw.length > 0 ? raw : undefined;
}, [state.communityContext]);
const rawCommunityContextForModal =
typeof state.communityContext === "string" ? state.communityContext : "";
const rawTitleForModal =
typeof state.title === "string" ? state.title : "";
const descriptionEmptyHint = t("communityContextEditModal.emptyHint");
const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
const storedFallback =
typeof state.previewSurfaceColor === "string"
? state.previewSurfaceColor
: undefined;
const persistFallback = useCallback(
(color: string) => {
replaceState((prev) => {
persistDraftPreviewSurfaceColor(color, prev);
const keep =
(prev.previewSurfaceColor &&
isPreviewSurfaceColor(prev.previewSurfaceColor)
? prev.previewSurfaceColor
: undefined) ??
readDraftPreviewSurfaceColor() ??
color;
return prev.previewSurfaceColor === keep
? prev
: { ...prev, previewSurfaceColor: keep };
});
},
[replaceState],
);
const surfaceColor = usePreviewSurfaceColor(
avatarUrl,
storedFallback,
persistFallback,
);
return (
<CreateFlowLockupCardStepShell
lockupTitle={
variant === "editPublished" ? t("editPublishedTitle") : t("title")
}
lockupDescription={
variant === "editPublished"
? t("editPublishedDescription")
: t("description")
}
>
{bindingArbitrationNeedsSeparateYes(state) ? (
<Alert
type="banner"
status="warning"
size="m"
title={t("bindingArbitrationWarning.title")}
description={t("bindingArbitrationWarning.description")}
hasTrailingIcon={false}
/>
) : null}
<Rule
title={ruleCardTitle}
description={ruleCardDescription}
onTitleClick={() => setTitleModalOpen(true)}
titleEditAriaLabel={t("titleEditModal.ariaEditTitle")}
onDescriptionClick={() => setCommunityContextModalOpen(true)}
descriptionEmptyHint={descriptionEmptyHint}
descriptionEditAriaLabel={t("communityContextEditModal.ariaEditDescription")}
size={mdUp ? "L" : "M"}
expanded={true}
descriptionAtBottom
backgroundColor="bg-transparent"
surfaceColor={surfaceColor}
logoUrl={avatarUrl}
logoAlt={ruleCardTitle}
communityInitials={
avatarUrl ? undefined : communityInitialsFromTitle(ruleCardTitle)
}
categories={finalReviewCategories}
className={`${CREATE_FLOW_REVIEW_RULE_LAYOUT_CLASS}${surfaceColor ? "" : " invisible"}`}
onClick={() => {}}
/>
<FinalReviewChipEditModal
isOpen={activeEditTarget !== null}
onClose={() => setActiveEditTarget(null)}
target={activeEditTarget}
state={state}
onSave={handleSave}
replaceState={replaceState}
onInteract={markCreateFlowInteraction}
onEditTargetChange={setActiveEditTarget}
/>
<TemplateChipDetailModal
isOpen={activeReadOnlyDetail !== null}
onClose={() => setActiveReadOnlyDetail(null)}
detail={activeReadOnlyDetail}
/>
<FinalReviewTitleEditModal
isOpen={titleModalOpen}
onClose={() => setTitleModalOpen(false)}
initialValue={rawTitleForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ title: value });
}}
/>
<FinalReviewCommunityContextEditModal
isOpen={communityContextModalOpen}
onClose={() => setCommunityContextModalOpen(false)}
initialValue={rawCommunityContextForModal}
onSave={(value) => {
markCreateFlowInteraction();
updateState({ communityContext: value, summary: value });
}}
/>
</CreateFlowLockupCardStepShell>
);
}