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:
moh
2026-09-20 01:56:50 +02:00
parent a13e33edcd
commit d78450fc47
5 changed files with 56 additions and 25 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"name": "dev", "name": "dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3014
} }
] ]
} }
+14 -16
View File
@@ -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 />
+7 -1
View File
@@ -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,
+12 -4
View File
@@ -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.`,
}, },
]; ];
} }
+22 -3
View File
@@ -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", () => {