ADDED - Save an unfinished project as a draft

- Add a Save as draft button that bypasses the completeness gate: the user
  can save partial work and finish later
- projectDraftInputSchema makes the user-facing copy (title, summary, service
  label, client) optional; the action auto-generates a slug from the title
  (or draft-<timestamp>), defaults the year to the current year, and forces
  the project unpublished
- Drafts drop not-yet-valid sections/assets instead of failing the save
- Pass intent via a hidden field set on click so the server reliably sees it
- Tests: draft schema accepts empty copy a strict save rejects, and still
  requires a slug and a valid year
This commit is contained in:
moh
2026-09-20 16:52:13 +02:00
parent 39e459fa09
commit 4b535694bc
4 changed files with 164 additions and 14 deletions
+60 -10
View File
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
projectDraftInputSchema,
projectInputSchema, projectInputSchema,
sectionInputSchema, sectionInputSchema,
} from "@/lib/portfolio-validation"; } from "@/lib/portfolio-validation";
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
function slugifyForDraft(input: string): string {
const base = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base || `draft-${Date.now()}`;
}
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie(); await clearAdminSessionCookie();
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = []; const createdMediaAssetIds: string[] = [];
try { try {
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) => const intent = String(formData.get("intent") ?? "save");
const isDraft = intent === "draft";
const parseSection = (section: Record<string, unknown>, index: number) =>
sectionInputSchema.parse({ sectionInputSchema.parse({
...section, ...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined, media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index, sortOrder: section.sortOrder ?? index,
}), });
);
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) => const parseAsset = (asset: Record<string, unknown>, index: number) =>
assetInputSchema.parse({ assetInputSchema.parse({
...asset, ...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined, media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index, sortOrder: asset.sortOrder ?? index,
}), });
);
// A draft keeps only the entries that are already valid; a full save
// validates every entry strictly.
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
if (!isDraft) {
return [parseSection(section, index)];
}
try {
return [parseSection(section, index)];
} catch {
return [];
}
});
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
if (!isDraft) {
return [parseAsset(asset, index)];
}
try {
return [parseAsset(asset, index)];
} catch {
return [];
}
});
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia"); const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
const parsed = projectInputSchema.parse({ const rawSlug = String(formData.get("slug") ?? "").trim();
const slug =
isDraft && !rawSlug
? slugifyForDraft(
String(formData.get("titleDe") ?? "") ||
String(formData.get("titleEn") ?? "") ||
String(formData.get("titleAr") ?? ""),
)
: rawSlug;
const rawYear = String(formData.get("projectYear") ?? "").trim();
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
id: String(formData.get("id") ?? "").trim() || undefined, id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""), categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""), slug,
viewMode: String(formData.get("viewMode") ?? "GRID"), viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""), titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""), titleEn: String(formData.get("titleEn") ?? ""),
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""), summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""), summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""), clientName: String(formData.get("clientName") ?? ""),
projectYear: String(formData.get("projectYear") ?? ""), projectYear,
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""), serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""), serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""), serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined, coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"), sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"), isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: normalizeCheckboxValue(formData, "isPublished"), isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
sections, sections,
assets, assets,
}); });
+27 -4
View File
@@ -347,16 +347,26 @@ function SectionHeader({
); );
} }
function SubmitButton() { function SubmitButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
<Button type="submit" disabled={pending}> <Button type="submit" onClick={onSelect} disabled={pending}>
{pending ? "Saving..." : "Save Project"} {pending ? "Saving..." : "Save Project"}
</Button> </Button>
); );
} }
function DraftButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" onClick={onSelect} variant="outline" disabled={pending}>
Save as draft
</Button>
);
}
function ViewModeCard({ function ViewModeCard({
active, active,
label, label,
@@ -466,6 +476,12 @@ export function PortfolioProjectForm({
const [showValidation, setShowValidation] = useState(false); const [showValidation, setShowValidation] = useState(false);
const sectionsInputRef = useRef<HTMLInputElement | null>(null); const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null); const assetsInputRef = useRef<HTMLInputElement | null>(null);
const intentRef = useRef<HTMLInputElement | null>(null);
const setIntent = (value: "save" | "draft") => {
if (intentRef.current) {
intentRef.current.value = value;
}
};
const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index }))); const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index })));
const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index }))); const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index })));
@@ -540,6 +556,11 @@ export function PortfolioProjectForm({
action={action} action={action}
className="space-y-8" className="space-y-8"
onSubmit={(event) => { onSubmit={(event) => {
// A draft save skips the completeness gate entirely.
if (intentRef.current?.value === "draft") {
return;
}
if (!firstIncompleteStep) { if (!firstIncompleteStep) {
return; return;
} }
@@ -554,6 +575,7 @@ export function PortfolioProjectForm({
<input type="hidden" name="viewMode" value={projectState.viewMode} /> <input type="hidden" name="viewMode" value={projectState.viewMode} />
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} /> <input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} /> <input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
<AppCard level={3} layer="single" padding="lg" className="space-y-6"> <AppCard level={3} layer="single" padding="lg" className="space-y-6">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -1186,14 +1208,15 @@ 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">
Fill Basics and Localized Content to save. Sections and assets are optional. Fill Basics and Localized Content to publish or save as a draft anytime.
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Ready to save. Sections and assets are optional. Ready to save. Sections and assets are optional.
</p> </p>
)} )}
<SubmitButton /> <DraftButton onSelect={() => setIntent("draft")} />
<SubmitButton onSelect={() => setIntent("save")} />
</div> </div>
</div> </div>
</AppCard> </AppCard>
+18
View File
@@ -123,3 +123,21 @@ export const projectInputSchema = z.object({
sections: z.array(sectionInputSchema), sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema), assets: z.array(assetInputSchema),
}); });
/**
* Draft variant: the user-facing copy fields are optional so an unfinished
* project can be saved and completed later. The action still guarantees a
* slug, a category, and a year (auto-filled), and forces the project unpublished.
*/
export const projectDraftInputSchema = projectInputSchema.extend({
titleAr: optionalTrimmedText,
titleEn: optionalTrimmedText,
titleDe: optionalTrimmedText,
summaryAr: optionalTrimmedText,
summaryEn: optionalTrimmedText,
summaryDe: optionalTrimmedText,
serviceLabelAr: optionalTrimmedText,
serviceLabelEn: optionalTrimmedText,
serviceLabelDe: optionalTrimmedText,
clientName: optionalTrimmedText,
});
+59
View File
@@ -9,6 +9,7 @@ import {
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
projectDraftInputSchema,
projectInputSchema, projectInputSchema,
sectionInputSchema, sectionInputSchema,
} from "../lib/portfolio-validation"; } from "../lib/portfolio-validation";
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
).toThrow(/slug/i); ).toThrow(/slug/i);
}); });
it("draft schema accepts empty user-facing copy that a strict save rejects", () => {
const draftPayload = {
categoryId: "cat_1",
slug: "draft-123",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(projectDraftInputSchema.parse(draftPayload).slug).toBe("draft-123");
expect(() => projectInputSchema.parse(draftPayload)).toThrow();
});
it("draft schema still requires a slug and a valid year", () => {
const base = {
categoryId: "cat_1",
slug: "draft-1",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(() => projectDraftInputSchema.parse({ ...base, slug: "" })).toThrow(/slug/i);
expect(() => projectDraftInputSchema.parse({ ...base, projectYear: 1999 })).toThrow();
});
it("accepts valid section and asset payloads", () => { it("accepts valid section and asset payloads", () => {
expect( expect(
sectionInputSchema.parse({ sectionInputSchema.parse({