FIXED - Make adding a portfolio project simpler and actually saveable
- Fix critical bug: TabsContent unmounted inactive panels, so the project form only submitted the active tab's fields and could never save; add an opt-in forceMount that keeps panels mounted (hidden) and use it on all four project-form tabs - Make Sections and Assets optional: a project saves with just Basics and Localized Content; new projects start with no sections/assets, and the last one can now be removed - Update wizard progress so empty sections/assets steps count as complete - Update save hint copy and step labels to reflect the optional steps - Correct .claude/launch.json dev port to 3014 - Update portfolio-form-progress tests for the optional-steps behaviour
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"name": "dev",
|
"name": "dev",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3000
|
"port": 3014
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,13 +165,13 @@ const wizardSteps: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sections",
|
key: "sections",
|
||||||
label: "Sections",
|
label: "Sections (optional)",
|
||||||
description: "Build the project story with ordered content blocks.",
|
description: "Optional — build the project story with ordered content blocks.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assets",
|
key: "assets",
|
||||||
label: "Assets & Media",
|
label: "Assets & Media (optional)",
|
||||||
description: "Cover and gallery assets from the media library.",
|
description: "Optional — cover and gallery assets from the media library.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -233,7 +233,8 @@ function createInitialState(
|
|||||||
|
|
||||||
function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] {
|
function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] {
|
||||||
if (!project?.sections.length) {
|
if (!project?.sections.length) {
|
||||||
return [createEmptySection(0)];
|
// Sections are optional — start empty so a project can be saved without them.
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return project.sections.map((section, index) => ({
|
return project.sections.map((section, index) => ({
|
||||||
@@ -259,7 +260,8 @@ function createInitialSections(project: PortfolioProjectView | null | undefined)
|
|||||||
|
|
||||||
function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] {
|
function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] {
|
||||||
if (!project?.assets.length) {
|
if (!project?.assets.length) {
|
||||||
return [createEmptyAsset(0)];
|
// Gallery assets are optional — start empty so a project can be saved without them.
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return project.assets.map((asset, index) => ({
|
return project.assets.map((asset, index) => ({
|
||||||
@@ -732,7 +734,7 @@ export function PortfolioProjectForm({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<TabsContent value="basics" className="mt-0">
|
<TabsContent value="basics" className="mt-0" forceMount>
|
||||||
<AppCard level={3} layer="single" padding="lg">
|
<AppCard level={3} layer="single" padding="lg">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -894,7 +896,7 @@ export function PortfolioProjectForm({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="content" className="mt-0">
|
<TabsContent value="content" className="mt-0" forceMount>
|
||||||
<AppCard level={3} layer="single" padding="lg">
|
<AppCard level={3} layer="single" padding="lg">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -945,7 +947,7 @@ export function PortfolioProjectForm({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sections" className="mt-0">
|
<TabsContent value="sections" className="mt-0" forceMount>
|
||||||
<AppCard level={3} layer="single" padding="lg">
|
<AppCard level={3} layer="single" padding="lg">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -1021,12 +1023,10 @@ export function PortfolioProjectForm({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
sections.length > 1 &&
|
|
||||||
setSections((current) =>
|
setSections((current) =>
|
||||||
current.filter((_, currentIndex) => currentIndex !== index),
|
current.filter((_, currentIndex) => currentIndex !== index),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={sections.length === 1}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1137,7 +1137,7 @@ export function PortfolioProjectForm({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="assets" className="mt-0">
|
<TabsContent value="assets" className="mt-0" forceMount>
|
||||||
<AppCard level={3} layer="single" padding="lg">
|
<AppCard level={3} layer="single" padding="lg">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
@@ -1228,12 +1228,10 @@ export function PortfolioProjectForm({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
assets.length > 1 &&
|
|
||||||
setAssets((current) =>
|
setAssets((current) =>
|
||||||
current.filter((_, currentIndex) => currentIndex !== index),
|
current.filter((_, currentIndex) => currentIndex !== index),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={assets.length === 1}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1347,11 +1345,11 @@ export function PortfolioProjectForm({
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{firstIncompleteStep ? (
|
{firstIncompleteStep ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Save becomes available after all steps are complete.
|
Fill Basics and Localized Content to save. Sections and assets are optional.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
All steps are ready. You can save now.
|
Ready to save. Sections and assets are optional.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
|
|||||||
@@ -98,22 +98,28 @@ function TabsTrigger({
|
|||||||
|
|
||||||
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
value: string;
|
value: string;
|
||||||
|
// Keep the panel mounted while inactive (hidden via `hidden`) so its form
|
||||||
|
// fields still submit. Use for tabbed forms that share one submit button.
|
||||||
|
forceMount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
children,
|
children,
|
||||||
|
forceMount = false,
|
||||||
...props
|
...props
|
||||||
}: TabsContentProps) {
|
}: TabsContentProps) {
|
||||||
const { value: activeValue } = useTabsContext();
|
const { value: activeValue } = useTabsContext();
|
||||||
|
const isActive = activeValue === value;
|
||||||
|
|
||||||
if (activeValue !== value) {
|
if (!isActive && !forceMount) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
hidden={!isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -136,13 +136,21 @@ export function getPortfolioWizardProgress(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sections",
|
key: "sections",
|
||||||
complete: input.sections.length > 0 && completedSections === input.sections.length,
|
// Optional: no sections is valid. If sections were added, each must be ready.
|
||||||
summary: `${completedSections}/${input.sections.length} sections ready.`,
|
complete: completedSections === input.sections.length,
|
||||||
|
summary:
|
||||||
|
input.sections.length === 0
|
||||||
|
? "Optional — no sections added."
|
||||||
|
: `${completedSections}/${input.sections.length} sections ready.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assets",
|
key: "assets",
|
||||||
complete: input.assets.length > 0 && completedAssets === input.assets.length,
|
// Optional: no gallery assets is valid. If assets were added, each must be ready.
|
||||||
summary: `${completedAssets}/${input.assets.length} assets ready.`,
|
complete: completedAssets === input.assets.length,
|
||||||
|
summary:
|
||||||
|
input.assets.length === 0
|
||||||
|
? "Optional — no gallery assets added."
|
||||||
|
: `${completedAssets}/${input.assets.length} assets ready.`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,10 +117,29 @@ describe("getPortfolioWizardProgress", () => {
|
|||||||
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
|
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sections/assets steps require at least one ready entry", () => {
|
it("treats sections/assets as optional: empty steps are complete and the project is saveable", () => {
|
||||||
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
||||||
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
|
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(true);
|
||||||
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
|
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(true);
|
||||||
|
// Basics + content only is enough to save.
|
||||||
|
expect(getFirstIncompleteWizardStep(noEntries)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an optional summary when no sections/assets are added", () => {
|
||||||
|
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
||||||
|
expect(noEntries.find((s) => s.key === "sections")?.summary).toBe("Optional — no sections added.");
|
||||||
|
expect(noEntries.find((s) => s.key === "assets")?.summary).toBe(
|
||||||
|
"Optional — no gallery assets added.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks saving when an added section is incomplete", () => {
|
||||||
|
const progress = getPortfolioWizardProgress({
|
||||||
|
...completeInput,
|
||||||
|
sections: [section({ titleEn: "" })],
|
||||||
|
});
|
||||||
|
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
|
||||||
|
expect(getFirstIncompleteWizardStep(progress)).toBe("sections");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("summarizes section/asset readiness counts", () => {
|
it("summarizes section/asset readiness counts", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user