Edit add feature refined
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
import type { CreateFlowMethodCardFacetSection } from "../types";
|
import type { CreateFlowMethodCardFacetSection } from "../types";
|
||||||
import {
|
import {
|
||||||
@@ -18,14 +18,17 @@ type MethodEntry = { id: string; label: string; supportText: string };
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
|
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
|
||||||
* order, and clears the pin draft flag when a section loses all selections.
|
* order. Rows stay pinned across navigation while `methodSectionsPinCommitted` is true
|
||||||
|
* and the section still has selections; we do **not** clear the flag when selection
|
||||||
|
* arrays briefly go empty during draft hydration (`replaceState` / merge flashes) —
|
||||||
|
* display order already ignores the pin until `pinActive` is true again.
|
||||||
*/
|
*/
|
||||||
export function useMethodCardDeckOrdering(
|
export function useMethodCardDeckOrdering(
|
||||||
section: RecommendationSection,
|
section: RecommendationSection,
|
||||||
methods: readonly MethodEntry[],
|
methods: readonly MethodEntry[],
|
||||||
selectedIds: readonly string[],
|
selectedIds: readonly string[],
|
||||||
) {
|
) {
|
||||||
const { state, setMethodSectionsPinCommitted } = useCreateFlow();
|
const { state } = useCreateFlow();
|
||||||
const facetKey = section as CreateFlowMethodCardFacetSection;
|
const facetKey = section as CreateFlowMethodCardFacetSection;
|
||||||
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
||||||
|
|
||||||
@@ -33,17 +36,6 @@ export function useMethodCardDeckOrdering(
|
|||||||
state.methodSectionsPinCommitted?.[facetKey] === true;
|
state.methodSectionsPinCommitted?.[facetKey] === true;
|
||||||
const pinActive = Boolean(pinStored && selectedIds.length > 0);
|
const pinActive = Boolean(pinStored && selectedIds.length > 0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIds.length > 0) return;
|
|
||||||
if (!pinStored) return;
|
|
||||||
setMethodSectionsPinCommitted(facetKey, false);
|
|
||||||
}, [
|
|
||||||
facetKey,
|
|
||||||
pinStored,
|
|
||||||
selectedIds.length,
|
|
||||||
setMethodSectionsPinCommitted,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rankedMethods = useMemo(
|
const rankedMethods = useMemo(
|
||||||
() => rankMethodsByScore(methods, scoresBySlug),
|
() => rankMethodsByScore(methods, scoresBySlug),
|
||||||
[methods, scoresBySlug],
|
[methods, scoresBySlug],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ChipOption } from "../../../../components/controls/MultiSelect/Mul
|
|||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import ContentLockup from "../../../../components/type/ContentLockup";
|
import ContentLockup from "../../../../components/type/ContentLockup";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
|
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import type {
|
import type {
|
||||||
CommunityStructureChipSnapshotRow,
|
CommunityStructureChipSnapshotRow,
|
||||||
@@ -57,31 +58,6 @@ function normalizeCoreValuePresets(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
|
||||||
return presets.map((row, i) => ({
|
|
||||||
id: String(i + 1),
|
|
||||||
label: row.label,
|
|
||||||
state: "unselected" as const,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySavedSelection(
|
|
||||||
options: ChipOption[],
|
|
||||||
saved: string[] | undefined,
|
|
||||||
): ChipOption[] {
|
|
||||||
const selected = new Set(saved ?? []);
|
|
||||||
return options.map((opt) =>
|
|
||||||
opt.state === "custom"
|
|
||||||
? opt
|
|
||||||
: {
|
|
||||||
...opt,
|
|
||||||
state: selected.has(opt.id)
|
|
||||||
? ("selected" as const)
|
|
||||||
: ("unselected" as const),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "selected")
|
.filter((o) => o.state === "selected")
|
||||||
@@ -98,19 +74,6 @@ function chipOptionsToSnapshotRows(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotRowsToChipOptions(
|
|
||||||
rows: CommunityStructureChipSnapshotRow[] | undefined,
|
|
||||||
): ChipOption[] | null {
|
|
||||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
||||||
return rows.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
label: r.label,
|
|
||||||
...(r.state !== undefined
|
|
||||||
? { state: r.state as ChipOption["state"] }
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
||||||
|
|
||||||
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
|
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
|
||||||
@@ -124,15 +87,12 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||||
|
|
||||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(
|
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
|
||||||
() => {
|
buildCoreValueChipOptionsFromDraft(
|
||||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
presets,
|
||||||
if (fromSnap) return fromSnap;
|
state.coreValuesChipsSnapshot,
|
||||||
return applySavedSelection(
|
state.selectedCoreValueIds,
|
||||||
chipRowsFromPresets(presets),
|
),
|
||||||
state.selectedCoreValueIds,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
|
const [activeModalChipId, setActiveModalChipId] = useState<string | null>(
|
||||||
@@ -142,15 +102,18 @@ export function CoreValuesSelectScreen() {
|
|||||||
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fromSnap = snapshotRowsToChipOptions(state.coreValuesChipsSnapshot);
|
setCoreValueOptions(
|
||||||
if (fromSnap) {
|
buildCoreValueChipOptionsFromDraft(
|
||||||
setCoreValueOptions(fromSnap);
|
presets,
|
||||||
return;
|
state.coreValuesChipsSnapshot,
|
||||||
}
|
state.selectedCoreValueIds,
|
||||||
setCoreValueOptions((prev) =>
|
),
|
||||||
applySavedSelection(prev, state.selectedCoreValueIds),
|
|
||||||
);
|
);
|
||||||
}, [state.coreValuesChipsSnapshot, state.selectedCoreValueIds]);
|
}, [
|
||||||
|
presets,
|
||||||
|
state.coreValuesChipsSnapshot,
|
||||||
|
state.selectedCoreValueIds,
|
||||||
|
]);
|
||||||
|
|
||||||
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
|
/** Sync chips to create-flow draft. Never call `updateState` from inside a `setCoreValueOptions` updater — defer with `queueMicrotask`. */
|
||||||
const syncCoreValuesToDraft = useCallback(
|
const syncCoreValuesToDraft = useCallback(
|
||||||
|
|||||||
@@ -83,13 +83,6 @@ export type ProfilePageViewProps = {
|
|||||||
const profileSectionHeadingClass =
|
const profileSectionHeadingClass =
|
||||||
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
"font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
|
||||||
|
|
||||||
/**
|
|
||||||
* Sticky `top` for page content below the product {@link Top} (standard variant).
|
|
||||||
* Must match `Top.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
|
|
||||||
*/
|
|
||||||
const stickyBelowTopTopClass =
|
|
||||||
"top-[41px] lg:top-[85px] xl:top-[89px]";
|
|
||||||
|
|
||||||
export type ProfilePageSignedOutViewProps = {
|
export type ProfilePageSignedOutViewProps = {
|
||||||
onSignIn: () => void;
|
onSignIn: () => void;
|
||||||
/** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
|
/** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
|
||||||
@@ -113,8 +106,8 @@ export function ProfilePageSignedOutView({
|
|||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
profileLgUp
|
profileLgUp
|
||||||
? `sticky z-10 bg-[var(--color-surface-default-primary)] ${stickyBelowTopTopClass}`
|
? "sticky top-0 z-10 bg-[var(--color-surface-default-primary)]"
|
||||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
: "flex flex-col gap-1 py-3 md:sticky md:top-0 md:z-10 md:bg-[var(--color-surface-default-primary)]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{profileLgUp ? (
|
{profileLgUp ? (
|
||||||
@@ -266,8 +259,8 @@ export function ProfilePageView({
|
|||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
profileLgUp
|
profileLgUp
|
||||||
? `lg:sticky lg:z-10 lg:bg-[var(--color-surface-default-primary)] lg:top-[85px] xl:top-[89px]`
|
? "lg:sticky lg:top-0 lg:z-10 lg:bg-[var(--color-surface-default-primary)]"
|
||||||
: `flex flex-col gap-1 py-3 md:sticky md:top-[41px] md:z-10 md:bg-[var(--color-surface-default-primary)]`
|
: "flex flex-col gap-1 py-3 md:sticky md:top-0 md:z-10 md:bg-[var(--color-surface-default-primary)]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{profileLgUp ? (
|
{profileLgUp ? (
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ function MultiSelectView({
|
|||||||
className={
|
className={
|
||||||
!addButtonText
|
!addButtonText
|
||||||
? // Circular button with border (Rule style)
|
? // Circular button with border (Rule style)
|
||||||
`bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
|
`cursor-pointer bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))] border-[1.25px] ${isInverse ? "border-[var(--color-border-default-primary,#141414)]" : "border-[var(--color-border-default-tertiary,#464646)]"} border-solid flex items-center justify-center ${isSmall ? "size-[30px]" : "size-[40px]"} rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity`
|
||||||
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
|
: // Text add control (default palette: white label + brand “+”; inverse: inverse primary for both)
|
||||||
`flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
|
`cursor-pointer flex items-center justify-center overflow-hidden rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity ${
|
||||||
isSmall
|
isSmall
|
||||||
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
|
? "gap-[var(--measures-spacing-100,4px)] px-[var(--measures-spacing-300,12px)] py-[var(--measures-spacing-200,8px)]"
|
||||||
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
|
: "gap-[var(--measures-spacing-150,6px)] px-[var(--space-400,16px)] py-[var(--measures-spacing-300,12px)]"
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ChipOption } from "../../app/components/controls/MultiSelect/MultiSelect.types";
|
||||||
|
import type {
|
||||||
|
CreateFlowState,
|
||||||
|
} from "../../app/(app)/create/types";
|
||||||
|
|
||||||
|
type CoreValuePreset = { label: string; meaning: string; signals: string };
|
||||||
|
|
||||||
|
function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[] {
|
||||||
|
return presets.map((row, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
label: row.label,
|
||||||
|
state: "unselected" as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySavedSelectionToPresetsOnly(
|
||||||
|
options: ChipOption[],
|
||||||
|
saved: string[] | undefined,
|
||||||
|
): ChipOption[] {
|
||||||
|
const selected = new Set(saved ?? []);
|
||||||
|
return options.map((opt) =>
|
||||||
|
opt.state === "custom"
|
||||||
|
? opt
|
||||||
|
: {
|
||||||
|
...opt,
|
||||||
|
state: selected.has(opt.id)
|
||||||
|
? ("selected" as const)
|
||||||
|
: ("unselected" as const),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valid MultiSelect chip state from snapshot JSON. */
|
||||||
|
function normalizeChipState(s: unknown): ChipOption["state"] | undefined {
|
||||||
|
return s === "selected" ||
|
||||||
|
s === "unselected" ||
|
||||||
|
s === "custom" ||
|
||||||
|
s === "error"
|
||||||
|
? (s as ChipOption["state"])
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the core-values MultiSelect chip list shown in-create.
|
||||||
|
*
|
||||||
|
* The published-rule hydration path writes only **selected** rows into
|
||||||
|
* `coreValuesChipsSnapshot`. Editing must still show every preset ("1"..N) plus
|
||||||
|
* custom ids from the snapshot. Card-deck pin / ordering features do not apply
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
export function buildCoreValueChipOptionsFromDraft(
|
||||||
|
presets: readonly CoreValuePreset[],
|
||||||
|
snapshot: CreateFlowState["coreValuesChipsSnapshot"],
|
||||||
|
selectedCoreValueIds: CreateFlowState["selectedCoreValueIds"],
|
||||||
|
): ChipOption[] {
|
||||||
|
const presetBase = chipRowsFromPresets(presets);
|
||||||
|
const presetIdSet = new Set(presetBase.map((p) => p.id));
|
||||||
|
const selected = new Set(selectedCoreValueIds ?? []);
|
||||||
|
|
||||||
|
if (!snapshot?.length) {
|
||||||
|
return applySavedSelectionToPresetsOnly(presetBase, selectedCoreValueIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapById = new Map(snapshot.map((r) => [r.id, r] as const));
|
||||||
|
|
||||||
|
const presetRows: ChipOption[] = presetBase.map((opt) => {
|
||||||
|
const row = snapById.get(opt.id);
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const normalized = normalizeChipState(row.state);
|
||||||
|
const effectiveState =
|
||||||
|
normalized ??
|
||||||
|
(selected.has(opt.id) ? ("selected" as const) : ("unselected" as const));
|
||||||
|
const label =
|
||||||
|
typeof row.label === "string" && row.label.trim().length > 0
|
||||||
|
? row.label
|
||||||
|
: opt.label;
|
||||||
|
return { ...opt, label, state: effectiveState };
|
||||||
|
});
|
||||||
|
|
||||||
|
const customRows: ChipOption[] = snapshot
|
||||||
|
.filter((r) => !presetIdSet.has(r.id))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
state:
|
||||||
|
normalizeChipState(r.state) ??
|
||||||
|
(selected.has(r.id) ? ("selected" as const) : ("unselected" as const)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...presetRows, ...customRows];
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { CommunityStructureChipSnapshotRow } from "../../app/(app)/create/types";
|
||||||
|
import { buildCoreValueChipOptionsFromDraft } from "../../lib/create/coreValueChipOptionsFromDraft";
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "A", meaning: "am", signals: "as" },
|
||||||
|
{ label: "B", meaning: "bm", signals: "bs" },
|
||||||
|
{ label: "C", meaning: "cm", signals: "cs" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("buildCoreValueChipOptionsFromDraft", () => {
|
||||||
|
it("shows every preset plus selection when snapshot only has selected rows (edit / hydrate)", () => {
|
||||||
|
const snapshot: CommunityStructureChipSnapshotRow[] = [
|
||||||
|
{ id: "1", label: "A", state: "selected" },
|
||||||
|
{ id: "3", label: "C", state: "selected" },
|
||||||
|
];
|
||||||
|
const out = buildCoreValueChipOptionsFromDraft(
|
||||||
|
PRESETS,
|
||||||
|
snapshot,
|
||||||
|
["1", "3"],
|
||||||
|
);
|
||||||
|
expect(out.map((r) => r.id)).toEqual(["1", "2", "3"]);
|
||||||
|
expect(out.map((r) => r.label)).toEqual(["A", "B", "C"]);
|
||||||
|
expect(out.map((r) => r.state)).toEqual(["selected", "unselected", "selected"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges preset labels/state from snapshot and appends non-preset (custom) rows", () => {
|
||||||
|
const snapshot: CommunityStructureChipSnapshotRow[] = [
|
||||||
|
{ id: "1", label: "Edited A", state: "unselected" },
|
||||||
|
{ id: "uuid-1", label: "Custom", state: "selected" },
|
||||||
|
];
|
||||||
|
const out = buildCoreValueChipOptionsFromDraft(PRESETS, snapshot, []);
|
||||||
|
expect(out).toHaveLength(4);
|
||||||
|
expect(out.filter((r) => r.id === "1")[0]?.label).toBe("Edited A");
|
||||||
|
expect(out.filter((r) => r.id === "uuid-1")[0]).toMatchObject({
|
||||||
|
id: "uuid-1",
|
||||||
|
label: "Custom",
|
||||||
|
state: "selected",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses selectedCoreValueIds when snapshot row lacks a valid state string", () => {
|
||||||
|
const snapshot: CommunityStructureChipSnapshotRow[] = [
|
||||||
|
{ id: "2", label: "B", state: "garbage" },
|
||||||
|
];
|
||||||
|
const out = buildCoreValueChipOptionsFromDraft(
|
||||||
|
PRESETS,
|
||||||
|
snapshot,
|
||||||
|
["2"],
|
||||||
|
);
|
||||||
|
expect(out.map((r) => ({ id: r.id, state: r.state }))).toContainEqual({
|
||||||
|
id: "2",
|
||||||
|
state: "selected",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with empty snapshot derives from presets and selected ids only", () => {
|
||||||
|
const out = buildCoreValueChipOptionsFromDraft(
|
||||||
|
PRESETS,
|
||||||
|
undefined,
|
||||||
|
["2"],
|
||||||
|
);
|
||||||
|
expect(out.map((r) => r.id)).toEqual(["1", "2", "3"]);
|
||||||
|
expect(out.filter((r) => r.state === "selected").map((r) => r.id)).toEqual(
|
||||||
|
["2"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user