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 {
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user