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:
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
|
||||
import {
|
||||
assetInputSchema,
|
||||
categoryInputSchema,
|
||||
projectDraftInputSchema,
|
||||
projectInputSchema,
|
||||
sectionInputSchema,
|
||||
} 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() {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
await clearAdminSessionCookie();
|
||||
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
|
||||
const createdMediaAssetIds: string[] = [];
|
||||
|
||||
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({
|
||||
...section,
|
||||
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
||||
sortOrder: section.sortOrder ?? index,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
|
||||
const parseAsset = (asset: Record<string, unknown>, index: number) =>
|
||||
assetInputSchema.parse({
|
||||
...asset,
|
||||
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
||||
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 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,
|
||||
categoryId: String(formData.get("categoryId") ?? ""),
|
||||
slug: String(formData.get("slug") ?? ""),
|
||||
slug,
|
||||
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
||||
titleAr: String(formData.get("titleAr") ?? ""),
|
||||
titleEn: String(formData.get("titleEn") ?? ""),
|
||||
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
summaryEn: String(formData.get("summaryEn") ?? ""),
|
||||
summaryDe: String(formData.get("summaryDe") ?? ""),
|
||||
clientName: String(formData.get("clientName") ?? ""),
|
||||
projectYear: String(formData.get("projectYear") ?? ""),
|
||||
projectYear,
|
||||
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
||||
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
||||
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
||||
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
||||
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
||||
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
||||
isPublished: normalizeCheckboxValue(formData, "isPublished"),
|
||||
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
|
||||
sections,
|
||||
assets,
|
||||
});
|
||||
|
||||
@@ -347,16 +347,26 @@ function SectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
function SubmitButton({ onSelect }: { onSelect: () => void }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
<Button type="submit" onClick={onSelect} disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Project"}
|
||||
</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({
|
||||
active,
|
||||
label,
|
||||
@@ -466,6 +476,12 @@ export function PortfolioProjectForm({
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const sectionsInputRef = 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 assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index })));
|
||||
@@ -540,6 +556,11 @@ export function PortfolioProjectForm({
|
||||
action={action}
|
||||
className="space-y-8"
|
||||
onSubmit={(event) => {
|
||||
// A draft save skips the completeness gate entirely.
|
||||
if (intentRef.current?.value === "draft") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstIncompleteStep) {
|
||||
return;
|
||||
}
|
||||
@@ -554,6 +575,7 @@ export function PortfolioProjectForm({
|
||||
<input type="hidden" name="viewMode" value={projectState.viewMode} />
|
||||
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
|
||||
<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">
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -1186,14 +1208,15 @@ export function PortfolioProjectForm({
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{firstIncompleteStep ? (
|
||||
<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 className="text-sm text-muted-foreground">
|
||||
Ready to save. Sections and assets are optional.
|
||||
</p>
|
||||
)}
|
||||
<SubmitButton />
|
||||
<DraftButton onSelect={() => setIntent("draft")} />
|
||||
<SubmitButton onSelect={() => setIntent("save")} />
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
@@ -123,3 +123,21 @@ export const projectInputSchema = z.object({
|
||||
sections: z.array(sectionInputSchema),
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assetInputSchema,
|
||||
categoryInputSchema,
|
||||
projectDraftInputSchema,
|
||||
projectInputSchema,
|
||||
sectionInputSchema,
|
||||
} from "../lib/portfolio-validation";
|
||||
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
sectionInputSchema.parse({
|
||||
|
||||
Reference in New Issue
Block a user