diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0c472d4a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,10 @@ +# Content Engine + +## Running a Single Cypress Spec (Recipe Editor) + +``` +cd websites/recipe-website/editor +pnpm exec start-server-and-test dev:test http://localhost:3000 "cypress run --spec cypress/e2e/.cy.ts" +``` + +Test runs attempt to use the same server port, so multiple runs must be executed sequentially. diff --git a/packages/content-engine/content/createContent.ts b/packages/content-engine/content/createContent.ts index 328c23b2..06dfdc15 100644 --- a/packages/content-engine/content/createContent.ts +++ b/packages/content-engine/content/createContent.ts @@ -1,8 +1,10 @@ import type { Key } from "lmdb"; +import { exists } from "fs-extra"; import { getContentDirectory } from "../fs/getContentDirectory"; import { commitContentChanges } from "../git/commit"; import { getContentDatabase, writeToIndex } from "./database"; import { + getContentItemDirectory, getUploadInfo, processUploadChanges, writeContentToFilesystem, @@ -13,6 +15,13 @@ import type { FileUploadData, } from "./types"; +export class SlugConflictError extends Error { + constructor(public readonly slug: string) { + super(`Content with slug "${slug}" already exists`); + this.name = "SlugConflictError"; + } +} + /** * Default upload processor for creating content. * Processes each upload field by writing new files. @@ -22,16 +31,19 @@ export async function defaultCreateUploadsProcessor( slug: string, uploads: Record, contentDirectory: string, -): Promise { +): Promise { + const paths: string[] = []; for (const [, uploadData] of Object.entries(uploads)) { - await processUploadChanges( + const uploadPaths = await processUploadChanges( config, slug, uploadData, undefined, // No existing file for create contentDirectory, ); + paths.push(...uploadPaths); } + return paths; } /** @@ -57,11 +69,9 @@ export async function defaultCreateUploadsProcessor( * }); * ``` */ -export async function createContent< - TData, - TIndexValue, - TKey extends Key, ->(options: CreateContentOptions): Promise { +export async function createContent( + options: CreateContentOptions, +): Promise { const { config, slug, @@ -71,9 +81,23 @@ export async function createContent< commitMessage, uploads, processUploads = defaultCreateUploadsProcessor, + action, } = options; const contentDirectory = providedContentDirectory || getContentDirectory(); + const touchedPaths: string[] = []; + + // 0. Check for slug conflict + if (action !== "overwrite") { + const itemDir = getContentItemDirectory( + config as ContentTypeConfig, + slug, + contentDirectory, + ); + if (await exists(itemDir)) { + throw new SlugConflictError(slug); + } + } // 1. Process uploads if (uploads) { @@ -81,21 +105,25 @@ export async function createContent< for (const [fieldName, spec] of Object.entries(uploads)) { resolvedUploads[fieldName] = await getUploadInfo(spec); } - await processUploads( + const uploadPaths = await processUploads( config as ContentTypeConfig, slug, resolvedUploads, contentDirectory, ); + if (uploadPaths) { + touchedPaths.push(...uploadPaths); + } } // 2. Write to filesystem - await writeContentToFilesystem( + const dataFilePath = await writeContentToFilesystem( config as ContentTypeConfig, slug, data, contentDirectory, ); + touchedPaths.push(dataFilePath); // 3. Write to index const db = getContentDatabase( @@ -112,7 +140,7 @@ export async function createContent< // 4. Commit to git const message = commitMessage || `Add new ${config.contentType}: ${slug}`; - await commitContentChanges(message, author); + await commitContentChanges(message, author, touchedPaths); } export default createContent; diff --git a/packages/content-engine/content/deleteContent.ts b/packages/content-engine/content/deleteContent.ts index 1eae8b39..589df5c4 100644 --- a/packages/content-engine/content/deleteContent.ts +++ b/packages/content-engine/content/deleteContent.ts @@ -41,7 +41,7 @@ export async function deleteContent< const contentDirectory = providedContentDirectory || getContentDirectory(); // 1. Delete from filesystem (including uploads) - await deleteContentFromFilesystem( + const deletedPaths = await deleteContentFromFilesystem( config as ContentTypeConfig, slug, contentDirectory, @@ -60,7 +60,7 @@ export async function deleteContent< // 3. Commit to git const message = commitMessage || `Delete ${config.contentType}: ${slug}`; - await commitContentChanges(message, author); + await commitContentChanges(message, author, deletedPaths); } export default deleteContent; diff --git a/packages/content-engine/content/filesystem.ts b/packages/content-engine/content/filesystem.ts index dd2eab38..e6daf97e 100644 --- a/packages/content-engine/content/filesystem.ts +++ b/packages/content-engine/content/filesystem.ts @@ -1,6 +1,6 @@ import { createWriteStream } from "fs"; import { ensureDir, exists, outputJSON, readJson, rename, rm } from "fs-extra"; -import { join, parse, resolve } from "path"; +import { join, parse, relative, resolve } from "path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { ReadableStream } from "node:stream/web"; @@ -93,7 +93,8 @@ export async function writeContentToFilesystem( slug: string, data: TData, contentDirectory?: string, -): Promise { +): Promise { + const baseDir = contentDirectory || getContentDirectory(); const itemDirectory = getContentItemDirectory( config as ContentTypeConfig, slug, @@ -103,6 +104,7 @@ export async function writeContentToFilesystem( await outputJSON(join(itemDirectory, config.dataFilename), data, { spaces: 2, }); + return relative(baseDir, join(itemDirectory, config.dataFilename)); } /** @@ -128,17 +130,24 @@ export async function deleteContentFromFilesystem( config: ContentTypeConfig, slug: string, contentDirectory?: string, -): Promise { +): Promise { + const baseDir = contentDirectory || getContentDirectory(); + const paths: string[] = []; + const itemDirectory = getContentItemDirectory(config, slug, contentDirectory); if (await exists(itemDirectory)) { + paths.push(relative(baseDir, itemDirectory)); await rm(itemDirectory, { recursive: true }); } // Also remove uploads directory if it exists const uploadsBase = getUploadsBaseDirectory(config, slug, contentDirectory); if (await exists(uploadsBase)) { + paths.push(relative(baseDir, uploadsBase)); await rm(uploadsBase, { recursive: true, force: true }); } + + return paths; } /** @@ -149,11 +158,16 @@ export async function renameContentDirectory( oldSlug: string, newSlug: string, contentDirectory?: string, -): Promise { +): Promise { + const baseDir = contentDirectory || getContentDirectory(); + const paths: string[] = []; + const oldDir = getContentItemDirectory(config, oldSlug, contentDirectory); const newDir = getContentItemDirectory(config, newSlug, contentDirectory); if (await exists(oldDir)) { + paths.push(relative(baseDir, oldDir)); + paths.push(relative(baseDir, newDir)); await rename(oldDir, newDir); } @@ -161,9 +175,13 @@ export async function renameContentDirectory( const oldUploadsDir = getUploadsDirectory(config, oldSlug, contentDirectory); const newUploadsDir = getUploadsDirectory(config, newSlug, contentDirectory); if (await exists(oldUploadsDir)) { + paths.push(relative(baseDir, oldUploadsDir)); + paths.push(relative(baseDir, newUploadsDir)); await ensureDir(resolve(newUploadsDir, "..")); await rename(oldUploadsDir, newUploadsDir); } + + return paths; } /** @@ -267,17 +285,24 @@ export async function processUploadChanges( uploadData: FileUploadData | undefined, existingFile: string | undefined, contentDirectory?: string, -): Promise { +): Promise { + const baseDir = contentDirectory || getContentDirectory(); + const paths: string[] = []; + // Check if file should be deleted if ( existingFile !== undefined && (uploadData === undefined || uploadData.file || uploadData.fileImportUrl) ) { + paths.push(relative(baseDir, getUploadFilePath(config, slug, existingFile, contentDirectory))); await removeUploadFile(config, slug, existingFile, contentDirectory); } // Write new file if provided if (uploadData) { + paths.push(relative(baseDir, getUploadFilePath(config, slug, uploadData.fileName, contentDirectory))); await writeUploadFile(config, slug, uploadData, contentDirectory); } + + return paths; } diff --git a/packages/content-engine/content/types.ts b/packages/content-engine/content/types.ts index 43a3eca7..8ea5fc6a 100644 --- a/packages/content-engine/content/types.ts +++ b/packages/content-engine/content/types.ts @@ -118,14 +118,16 @@ export interface CreateContentOptions< /** * Optional custom upload processor. If provided, this will be called * instead of the default upload processing. Receives the resolved - * upload data for each field. + * upload data for each field. Returns paths touched (relative to contentDirectory). */ processUploads?: ( config: ContentTypeConfig, slug: string, uploads: Record, contentDirectory: string, - ) => Promise; + ) => Promise; + + action?: "overwrite"; } /** @@ -167,6 +169,7 @@ export interface UpdateContentOptions< * Optional custom upload processor. If provided, this will be called * instead of the default upload processing. Receives the resolved * upload data for each field, plus additional context for updates. + * Returns paths touched (relative to contentDirectory). */ processUploads?: ( config: ContentTypeConfig, @@ -175,7 +178,7 @@ export interface UpdateContentOptions< contentDirectory: string, currentSlug: string, uploadSpecs: Record, - ) => Promise; + ) => Promise; } /** diff --git a/packages/content-engine/content/updateContent.ts b/packages/content-engine/content/updateContent.ts index 109f781d..7941d2a8 100644 --- a/packages/content-engine/content/updateContent.ts +++ b/packages/content-engine/content/updateContent.ts @@ -27,18 +27,21 @@ export async function defaultUpdateUploadsProcessor( contentDirectory: string, currentSlug: string, uploadSpecs: Record, -): Promise { +): Promise { + const paths: string[] = []; // Process uploads at current slug location before rename for (const [fieldName, uploadData] of Object.entries(uploads)) { const existingFile = uploadSpecs[fieldName]?.existingFile; - await processUploadChanges( + const uploadPaths = await processUploadChanges( config, currentSlug, uploadData, existingFile, contentDirectory, ); + paths.push(...uploadPaths); } + return paths; } /** @@ -86,6 +89,7 @@ export async function updateContent< const contentDirectory = providedContentDirectory || getContentDirectory(); const willRename = currentSlug !== slug; + const touchedPaths: string[] = []; // 1. Process uploads at current slug location (before rename) if (uploads) { @@ -95,7 +99,7 @@ export async function updateContent< } const uploadProcessor = processUploads || defaultUpdateUploadsProcessor; - await uploadProcessor( + const uploadPaths = await uploadProcessor( config as ContentTypeConfig, slug, resolvedUploads, @@ -103,25 +107,30 @@ export async function updateContent< currentSlug, uploads, ); + if (uploadPaths) { + touchedPaths.push(...uploadPaths); + } } // 2. Rename directories if slug changed if (willRename) { - await renameContentDirectory( + const renamePaths = await renameContentDirectory( config as ContentTypeConfig, currentSlug, slug, contentDirectory, ); + touchedPaths.push(...renamePaths); } // 3. Write to filesystem - await writeContentToFilesystem( + const dataFilePath = await writeContentToFilesystem( config as ContentTypeConfig, slug, data, contentDirectory, ); + touchedPaths.push(dataFilePath); // 4. Update index const db = getContentDatabase( @@ -147,17 +156,20 @@ export async function updateContent< // 5. Update references in content that references this type if (willRename && config.referencedBy && config.referencedBy.length > 0) { - await updateReferences({ + const refResults = await updateReferences({ oldSlug: currentSlug, newSlug: slug, referenceSpecs: config.referencedBy, contentDirectory, }); + for (const refResult of refResults) { + touchedPaths.push(...refResult.updatedPaths); + } } // 6. Commit to git const message = commitMessage || `Update ${config.contentType}: ${slug}`; - await commitContentChanges(message, author); + await commitContentChanges(message, author, touchedPaths); } export default updateContent; diff --git a/packages/content-engine/content/updateReferences.ts b/packages/content-engine/content/updateReferences.ts index 70499d67..295af48a 100644 --- a/packages/content-engine/content/updateReferences.ts +++ b/packages/content-engine/content/updateReferences.ts @@ -3,11 +3,13 @@ import { readdir } from "fs-extra"; import { getContentDirectory } from "../fs/getContentDirectory"; import { getContentDatabase, writeToIndex } from "./database"; import { + getContentFilePath, getDataDirectory, readContentFromFilesystem, writeContentToFilesystem, } from "./filesystem"; import type { ContentTypeConfig, ReferenceSpec } from "./types"; +import { relative } from "path"; /** * Result of updating references for a single content type @@ -16,6 +18,7 @@ export interface ReferenceUpdateResult { contentType: string; updatedCount: number; updatedSlugs: string[]; + updatedPaths: string[]; errors: Array<{ slug: string; error: string }>; } @@ -97,6 +100,7 @@ async function updateReferencesViaIndex< contentType: config.contentType, updatedCount: 0, updatedSlugs: [], + updatedPaths: [], errors: [], }; @@ -161,6 +165,8 @@ async function updateReferencesViaIndex< result.updatedCount++; result.updatedSlugs.push(slug); + const filePath = getContentFilePath(config as ContentTypeConfig, slug, contentDirectory); + result.updatedPaths.push(relative(contentDirectory, filePath)); } catch (error) { result.errors.push({ slug, @@ -198,6 +204,7 @@ async function updateReferencesViaFileScan< contentType: config.contentType, updatedCount: 0, updatedSlugs: [], + updatedPaths: [], errors: [], }; @@ -255,6 +262,8 @@ async function updateReferencesViaFileScan< result.updatedCount++; result.updatedSlugs.push(slug); + const filePath = getContentFilePath(config as ContentTypeConfig, slug, contentDirectory); + result.updatedPaths.push(relative(contentDirectory, filePath)); } } catch (error) { result.errors.push({ @@ -292,6 +301,7 @@ export async function updateReferencesForSpec< contentType: spec.config.contentType, updatedCount: 0, updatedSlugs: [], + updatedPaths: [], errors: [ { slug: "", diff --git a/packages/content-engine/demo/cypress.config.ts b/packages/content-engine/demo/cypress.config.ts index 86f917af..d3ccdc0b 100644 --- a/packages/content-engine/demo/cypress.config.ts +++ b/packages/content-engine/demo/cypress.config.ts @@ -40,6 +40,21 @@ export default defineConfig({ const log = await git.log(); return log.all.map((item) => item.message); }, + async getContentGitCommitFiles() { + const git = simpleGit(resolve("test-content")); + const log = await git.log(); + const result: Array<{ message: string; files: string[] }> = []; + for (let i = 0; i < log.all.length - 1; i++) { + const entry = log.all[i]; + const nextEntry = log.all[i + 1]; + const diff = await git.diffSummary([entry.hash, nextEntry.hash]); + result.push({ + message: entry.message, + files: diff.files.map((f) => f.file), + }); + } + return result; + }, async initializeContentGit() { const testContentDir = resolve("test-content"); await ensureDir(testContentDir); diff --git a/packages/content-engine/demo/cypress/e2e/git.cy.ts b/packages/content-engine/demo/cypress/e2e/git.cy.ts index df8c9286..f78d77cb 100644 --- a/packages/content-engine/demo/cypress/e2e/git.cy.ts +++ b/packages/content-engine/demo/cypress/e2e/git.cy.ts @@ -80,6 +80,128 @@ describe("Git Integration", function () { }); }); + describe("targeted commits", function () { + it("should only commit the note's data file when creating", function () { + cy.visit("/notes/new"); + cy.findByLabelText("Title *").type("Targeted Create Note"); + cy.findByRole("button", { name: "Create Note" }).click(); + cy.findByRole("heading", { name: "Targeted Create Note" }); + + cy.getContentGitCommitFiles().then((commits) => { + const createCommit = commits.find((c) => + c.message.includes("Create note: Targeted Create Note"), + ); + expect(createCommit).to.exist; + expect(createCommit!.files).to.have.length(1); + expect(createCommit!.files[0]).to.equal( + "notes/data/targeted-create-note/note.json", + ); + }); + }); + + it("should only commit the note's data file when updating without slug change", function () { + cy.visit("/notes/new"); + cy.findByLabelText("Title *").type("Targeted Update Note"); + cy.findByRole("button", { name: "Create Note" }).click(); + cy.findByRole("heading", { name: "Targeted Update Note" }); + + cy.findByRole("link", { name: "Edit" }).click(); + cy.findByLabelText("Title *").clear(); + cy.findByLabelText("Title *").type("Targeted Update Note Edited"); + cy.findByRole("button", { name: "Update Note" }).click(); + cy.findByRole("heading", { name: "Targeted Update Note Edited" }); + + cy.getContentGitCommitFiles().then((commits) => { + const updateCommit = commits.find((c) => + c.message.includes("Update note: Targeted Update Note Edited"), + ); + expect(updateCommit).to.exist; + expect(updateCommit!.files).to.have.length(1); + expect(updateCommit!.files[0]).to.equal( + "notes/data/targeted-update-note/note.json", + ); + }); + }); + + it("should commit old and new data dirs when updating with slug change", function () { + cy.visit("/notes/new"); + cy.findByLabelText("Title *").type("Slug Change Note"); + cy.findByRole("button", { name: "Create Note" }).click(); + cy.findByRole("heading", { name: "Slug Change Note" }); + + cy.findByRole("link", { name: "Edit" }).click(); + cy.findByLabelText("Slug").clear(); + cy.findByLabelText("Slug").type("renamed-slug-note"); + cy.findByRole("button", { name: "Update Note" }).click(); + cy.location("pathname").should("eq", "/notes/renamed-slug-note"); + + cy.getContentGitCommitFiles().then((commits) => { + const updateCommit = commits.find((c) => + c.message.includes("Update note: Slug Change Note"), + ); + expect(updateCommit).to.exist; + // Should include the old dir (deleted) and new dir (created) + const files = updateCommit!.files; + expect(files.some((f) => f.includes("slug-change-note"))).to.be.true; + expect(files.some((f) => f.includes("renamed-slug-note"))).to.be.true; + }); + }); + + it("should only commit the deleted note's directory when deleting", function () { + cy.visit("/notes/new"); + cy.findByLabelText("Title *").type("Targeted Delete Note"); + cy.findByRole("button", { name: "Create Note" }).click(); + cy.findByRole("heading", { name: "Targeted Delete Note" }); + + cy.findByRole("link", { name: "Delete" }).click(); + cy.findByRole("button", { name: "Yes, Delete Note" }).click(); + cy.findByText("Create New Note"); + + cy.getContentGitCommitFiles().then((commits) => { + const deleteCommit = commits.find((c) => + c.message.includes("Delete note: targeted-delete-note"), + ); + expect(deleteCommit).to.exist; + expect(deleteCommit!.files).to.have.length(1); + expect(deleteCommit!.files[0]).to.equal( + "notes/data/targeted-delete-note/note.json", + ); + }); + }); + + it("should commit both renamed note and updated bookmark files on reference update", function () { + // Create a note + cy.visit("/notes/new"); + cy.findByLabelText("Title *").type("Ref Update Note"); + cy.findByRole("button", { name: "Create Note" }).click(); + cy.findByRole("heading", { name: "Ref Update Note" }); + + // Create a bookmark for this note + cy.findByRole("link", { name: "Bookmark" }).click(); + cy.findByLabelText("Label *").type("Ref Update Bookmark"); + cy.findByRole("button", { name: "Create Bookmark" }).click(); + cy.findByRole("heading", { name: "Ref Update Bookmark" }); + + // Rename the note's slug + cy.visit("/notes/ref-update-note/edit"); + cy.findByLabelText("Slug").clear(); + cy.findByLabelText("Slug").type("ref-note-renamed"); + cy.findByRole("button", { name: "Update Note" }).click(); + cy.location("pathname").should("eq", "/notes/ref-note-renamed"); + + cy.getContentGitCommitFiles().then((commits) => { + const updateCommit = commits.find((c) => + c.message.includes("Update note: Ref Update Note"), + ); + expect(updateCommit).to.exist; + const files = updateCommit!.files; + // Should include the note's new data dir and the bookmark's data file + expect(files.some((f) => f.includes("ref-note-renamed"))).to.be.true; + expect(files.some((f) => f.includes("bookmarks/data/"))).to.be.true; + }); + }); + }); + describe("reference updates", function () { it("should update bookmark reference when note slug changes", function () { // Create a note @@ -95,7 +217,9 @@ describe("Git Integration", function () { // Verify bookmark was created and shows the note reference cy.findByRole("heading", { name: "My Bookmark" }); - cy.findByText("References note:").parent().should("contain", "note-to-bookmark"); + cy.findByText("References note:") + .parent() + .should("contain", "note-to-bookmark"); // Go back to home and verify bookmark shows on homepage cy.visit("/"); @@ -121,7 +245,9 @@ describe("Git Integration", function () { // Click the bookmark and verify it links to the renamed note cy.findByText("My Bookmark").click(); cy.findByRole("heading", { name: "My Bookmark" }); - cy.findByText("References note:").parent().should("contain", "renamed-note"); + cy.findByText("References note:") + .parent() + .should("contain", "renamed-note"); cy.findByRole("link", { name: /View Note:/ }).click(); cy.location("pathname").should("eq", "/notes/renamed-note"); }); diff --git a/packages/content-engine/demo/cypress/support/commands.ts b/packages/content-engine/demo/cypress/support/commands.ts index 7bec5d19..e5eb7f94 100644 --- a/packages/content-engine/demo/cypress/support/commands.ts +++ b/packages/content-engine/demo/cypress/support/commands.ts @@ -7,6 +7,9 @@ declare global { resetData(fixture?: string): Chainable; initializeContentGit(): Chainable; getContentGitLog(): Chainable; + getContentGitCommitFiles(): Chainable< + Array<{ message: string; files: string[] }> + >; checkNoteTitlesInOrder(titles: string[]): Chainable; copyFixtures(fixtureName: string): Chainable; } @@ -25,6 +28,12 @@ Cypress.Commands.add("getContentGitLog", () => { return cy.task("getContentGitLog"); }); +Cypress.Commands.add("getContentGitCommitFiles", () => { + return cy.task>( + "getContentGitCommitFiles", + ); +}); + Cypress.Commands.add("checkNoteTitlesInOrder", (titles: string[]) => { cy.findAllByRole("listitem").should("have.length", titles.length); cy.findAllByRole("listitem").each((el, i) => diff --git a/packages/content-engine/git/commit.ts b/packages/content-engine/git/commit.ts index 16fba116..f188d6ef 100644 --- a/packages/content-engine/git/commit.ts +++ b/packages/content-engine/git/commit.ts @@ -16,9 +16,10 @@ export async function commitChanges( contentDirectory: string, message: string, author?: { name: string; email: string }, + paths?: string[], ) { const git = simpleGit({ baseDir: contentDirectory }); - await git.add("./*"); + await git.add(paths && paths.length > 0 ? paths : "./*"); if (author) { await git.commit(message, { @@ -32,9 +33,10 @@ export async function commitChanges( export async function commitContentChanges( message: string, author?: { name: string; email: string }, + paths?: string[], ) { const contentDirectory = getContentDirectory(); if (await directoryIsGitRepo(contentDirectory)) { - await commitChanges(contentDirectory, message, author); + await commitChanges(contentDirectory, message, author, paths); } } diff --git a/websites/recipe-website/common/controller/featuredRecipeContentConfig.ts b/websites/recipe-website/common/controller/featuredRecipeContentConfig.ts index 1c253121..b4d22903 100644 --- a/websites/recipe-website/common/controller/featuredRecipeContentConfig.ts +++ b/websites/recipe-website/common/controller/featuredRecipeContentConfig.ts @@ -24,8 +24,7 @@ export const featuredRecipeContentConfig: ContentTypeConfig< slug: string, data: FeaturedRecipe, ): FeaturedRecipeEntryKey => [data.date, slug], - createDefaultSlug: (data: FeaturedRecipe) => - createDefaultFeaturedRecipeSlug({ date: data.date }), + createDefaultSlug: createDefaultFeaturedRecipeSlug, }; export default featuredRecipeContentConfig; diff --git a/websites/recipe-website/common/controller/featuredRecipeFormState.ts b/websites/recipe-website/common/controller/featuredRecipeFormState.ts index 582edfb4..4a46c93b 100644 --- a/websites/recipe-website/common/controller/featuredRecipeFormState.ts +++ b/websites/recipe-website/common/controller/featuredRecipeFormState.ts @@ -1,3 +1,5 @@ +import type { ContentFormState } from "recipe-website-common/controller/formState"; + export interface FeaturedRecipeFormErrors extends Record< string, string[] | undefined @@ -8,7 +10,4 @@ export interface FeaturedRecipeFormErrors extends Record< slug?: string[]; } -export type FeaturedRecipeFormState = { - errors?: FeaturedRecipeFormErrors; - message: string; -}; +export type FeaturedRecipeFormState = ContentFormState; diff --git a/websites/recipe-website/common/controller/formState.ts b/websites/recipe-website/common/controller/formState.ts index e98b5088..caf513f2 100644 --- a/websites/recipe-website/common/controller/formState.ts +++ b/websites/recipe-website/common/controller/formState.ts @@ -1,3 +1,19 @@ +import type { + Ingredient, + InstructionEntry, + Timeline, +} from "recipe-website-common/controller/types"; + +export type ContentFormState< + TErrors extends Record = Record, + TFormData = Record, +> = { + errors?: TErrors; + message: string; + slugConflict?: string; + formData?: TFormData; +}; + export interface RecipeFormErrors extends Record { description?: string[]; name?: string[]; @@ -5,7 +21,19 @@ export interface RecipeFormErrors extends Record { slug?: string[]; } -export type RecipeFormState = { - errors?: RecipeFormErrors; - message: string; +export type RecipeFormData = { + name?: string; + description?: string; + slug?: string; + date?: number; + ingredients?: Ingredient[]; + instructions?: InstructionEntry[]; + timelines?: Timeline[]; + prepTime?: number; + cookTime?: number; + totalTime?: number; + recipeYield?: string; + videoUrl?: string; }; + +export type RecipeFormState = ContentFormState; diff --git a/websites/recipe-website/common/controller/recipeContentConfig.ts b/websites/recipe-website/common/controller/recipeContentConfig.ts index d166a1c6..63a237fb 100644 --- a/websites/recipe-website/common/controller/recipeContentConfig.ts +++ b/websites/recipe-website/common/controller/recipeContentConfig.ts @@ -22,7 +22,7 @@ export const recipeContentConfig: ContentTypeConfig< data.date, slug, ], - createDefaultSlug: (data: Recipe) => createDefaultSlug({ name: data.name }), + createDefaultSlug: createDefaultSlug, referencedBy: [ { config: featuredRecipeContentConfig, diff --git a/websites/recipe-website/editor/controller/actions/editorContentConfig.ts b/websites/recipe-website/editor/controller/actions/editorContentConfig.ts new file mode 100644 index 00000000..85ff4a59 --- /dev/null +++ b/websites/recipe-website/editor/controller/actions/editorContentConfig.ts @@ -0,0 +1,71 @@ +import type { ContentFormState } from "recipe-website-common/controller/formState"; +import type { + ContentTypeConfig, + UploadSpec, +} from "content-engine/content/types"; +import type { Key } from "lmdb"; + +export type ContentSuccessConfig = { + itemBasePath: string; + listPaths: Array<{ path: string; type?: "page" | "layout" }>; + redirectTo?: (slug: string) => string; +}; + +export interface EditorContentConfig< + TData, + TIndexValue, + TKey extends Key, + TFormState extends ContentFormState, + TParsed = Record, +> { + contentConfig: ContentTypeConfig; + successConfig: ContentSuccessConfig; + + parseFormData: ( + formData: FormData, + ) => + | { success: true; parsed: TParsed } + | { success: false; state: TFormState }; + + buildCreateData: ( + parsed: TParsed, + contentDirectory: string, + ) => Promise<{ slug: string; data: TData }>; + + buildUpdateData: ( + parsed: TParsed, + currentSlug: string, + currentDate: number, + contentDirectory: string, + ) => Promise<{ slug: string; data: TData }>; + + buildCreateUploads?: ( + parsed: TParsed, + contentDirectory: string, + ) => Promise>; + + buildUpdateUploads?: ( + parsed: TParsed, + currentSlug: string, + contentDirectory: string, + ) => Promise>; + + buildCurrentIndexKey: (currentDate: number, currentSlug: string) => TKey; + + extractFormData?: (parsed: TParsed) => TFormState["formData"]; + + label: string; + + checkSlugConflict?: ( + slug: string, + contentDirectory: string, + ) => Promise; + + deleteConflictingContent?: ( + slug: string, + contentDirectory: string, + email: string, + ) => Promise; + + deleteSuccessConfig?: ContentSuccessConfig; +} diff --git a/websites/recipe-website/editor/controller/actions/featuredRecipes.ts b/websites/recipe-website/editor/controller/actions/featuredRecipes.ts index dcc68712..e94d7021 100644 --- a/websites/recipe-website/editor/controller/actions/featuredRecipes.ts +++ b/websites/recipe-website/editor/controller/actions/featuredRecipes.ts @@ -1,175 +1,86 @@ "use server"; -import { auth } from "@/auth"; -import { createContent } from "content-engine/content/createContent"; -import { deleteContent } from "content-engine/content/deleteContent"; import { rebuildIndex } from "content-engine/content/rebuildIndex"; -import { updateContent } from "content-engine/content/updateContent"; import { getContentDirectory } from "content-engine/fs/getContentDirectory"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; import slugify from "@sindresorhus/slugify"; import createDefaultFeaturedRecipeSlug from "recipe-website-common/controller/createFeaturedRecipeSlug"; import { featuredRecipeContentConfig } from "recipe-website-common/controller/featuredRecipeContentConfig"; -import { FeaturedRecipeFormState } from "recipe-website-common/controller/featuredRecipeFormState"; -import { +import type { FeaturedRecipeFormState } from "recipe-website-common/controller/featuredRecipeFormState"; +import type { FeaturedRecipe, FeaturedRecipeEntryKey, } from "recipe-website-common/controller/types"; import { z } from "zod"; -import parseFeaturedRecipeFormData from "../parseFeaturedRecipeFormData"; +import parseFeaturedRecipeFormData, { + ParsedFeaturedRecipeFormData, +} from "../parseFeaturedRecipeFormData"; +import type { EditorContentConfig } from "./editorContentConfig"; +import { createGenericActions } from "./genericActions"; -// Function to handle success actions like revalidating and redirecting -function handleFeaturedRecipeSuccess(slug: string, currentSlug?: string) { - if (currentSlug && currentSlug !== slug) { - revalidatePath("/featured-recipe/" + currentSlug); - } - revalidatePath("/featured-recipe/" + slug); - revalidatePath("/featured-recipes"); - revalidatePath("/"); - redirect("/"); -} - -export async function createFeaturedRecipe( - _prevState: FeaturedRecipeFormState | null, - formData: FormData, -): Promise { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - return { message: "Authentication required" }; - } - const { - user: { email }, - } = session; - - const contentDirectory = getContentDirectory(); - - const formResult = parseFeaturedRecipeFormData(formData); - - if (!formResult.success) { - return { - errors: z.flattenError(formResult.error).fieldErrors, - message: "Error parsing featured recipe", +const featuredRecipeEditorConfig: EditorContentConfig< + FeaturedRecipe, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + FeaturedRecipeEntryKey, + FeaturedRecipeFormState, + ParsedFeaturedRecipeFormData +> = { + contentConfig: featuredRecipeContentConfig, + successConfig: { + itemBasePath: "/featured-recipe", + listPaths: [{ path: "/featured-recipes" }], + redirectTo: () => "/", + }, + label: "featured recipe", + + parseFormData(formData: FormData) { + const formResult = parseFeaturedRecipeFormData(formData); + if (!formResult.success) { + return { + success: false as const, + state: { + errors: z.flattenError(formResult.error).fieldErrors, + message: "Error parsing featured recipe", + }, + }; + } + return { success: true as const, parsed: formResult.data }; + }, + + async buildCreateData(parsed) { + const date: number = parsed.date || Date.now(); + const slug = slugify( + parsed.slug || createDefaultFeaturedRecipeSlug({ date }), + ); + const data: FeaturedRecipe = { + recipe: parsed.recipe, + date, + note: parsed.note, }; - } - - const { date: givenDate, slug: givenSlug, recipe, note } = formResult.data; - - const date: number = givenDate || (Date.now() as number); - const slug = slugify(givenSlug || createDefaultFeaturedRecipeSlug({ date })); - - const data: FeaturedRecipe = { - recipe, - date, - note, - }; - - try { - await createContent({ - config: featuredRecipeContentConfig, - slug, - data, - contentDirectory, - author: { name: email, email }, - commitMessage: `Add new featured recipe: ${slug}`, - }); - } catch { - return { message: "Failed to create featured recipe" }; - } - - handleFeaturedRecipeSuccess(slug); - - return { message: "Featured recipe creation successful!" }; -} - -export async function updateFeaturedRecipe( - currentDate: number, - currentSlug: string, - _prevState: FeaturedRecipeFormState | null, - formData: FormData, -): Promise { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - return { message: "Authentication required" }; - } - const { - user: { email }, - } = session; - - const contentDirectory = getContentDirectory(); - - const formResult = parseFeaturedRecipeFormData(formData); - - if (!formResult.success) { - return { - errors: z.flattenError(formResult.error).fieldErrors, - message: "Failed to update Featured Recipe.", + return { slug, data }; + }, + + async buildUpdateData(parsed, currentSlug, currentDate) { + const slug = slugify(parsed.slug || currentSlug); + const date = parsed.date || currentDate || Date.now(); + const data: FeaturedRecipe = { + recipe: parsed.recipe, + date, + note: parsed.note, }; - } - - const { date, slug: givenSlug, recipe, note } = formResult.data; - - const finalSlug = slugify(givenSlug || currentSlug); - const finalDate = date || currentDate || Date.now(); - - const data: FeaturedRecipe = { - recipe, - date: finalDate, - note, - }; - - const currentIndexKey: FeaturedRecipeEntryKey = [currentDate, currentSlug]; - - try { - // updateContent handles directory rename if slug changed - await updateContent({ - config: featuredRecipeContentConfig, - slug: finalSlug, - currentSlug, - currentIndexKey, - data, - contentDirectory, - author: { name: email, email }, - commitMessage: `Update featured recipe: ${finalSlug}`, - }); - } catch { - return { message: "Failed to update featured recipe" }; - } - - handleFeaturedRecipeSuccess(finalSlug, currentSlug); - - return { message: "Featured recipe update successful!" }; -} - -export async function deleteFeaturedRecipe(date: number, slug: string) { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - throw new Error("Authentication required"); - } - const { - user: { email }, - } = session; - - const contentDirectory = getContentDirectory(); - const indexKey: FeaturedRecipeEntryKey = [date, slug]; - - await deleteContent({ - config: featuredRecipeContentConfig, - slug, - indexKey, - contentDirectory, - author: { name: email, email }, - commitMessage: `Delete featured recipe: ${slug}`, - }); - - revalidatePath("/featured-recipe/" + slug); - revalidatePath("/featured-recipes"); - revalidatePath("/"); - redirect("/"); -} + return { slug, data }; + }, + + buildCurrentIndexKey(currentDate, currentSlug) { + return [currentDate, currentSlug]; + }, +}; + +const featuredRecipeActions = createGenericActions(featuredRecipeEditorConfig); +export const createFeaturedRecipe = featuredRecipeActions.create; +export const updateFeaturedRecipe = featuredRecipeActions.update; +export const deleteFeaturedRecipe = featuredRecipeActions.delete; export async function rebuildFeaturedRecipeIndex() { const contentDirectory = getContentDirectory(); diff --git a/websites/recipe-website/editor/controller/actions/genericActions.ts b/websites/recipe-website/editor/controller/actions/genericActions.ts new file mode 100644 index 00000000..9976c14f --- /dev/null +++ b/websites/recipe-website/editor/controller/actions/genericActions.ts @@ -0,0 +1,341 @@ +import type { Key } from "lmdb"; +import { + createContent, + SlugConflictError, +} from "content-engine/content/createContent"; +import { deleteContent } from "content-engine/content/deleteContent"; +import { updateContent } from "content-engine/content/updateContent"; +import { getContentDirectory } from "content-engine/fs/getContentDirectory"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import type { ContentFormState } from "recipe-website-common/controller/formState"; +import { authenticateUser } from "./shared"; +import type { + ContentSuccessConfig, + EditorContentConfig, +} from "./editorContentConfig"; + +function handleContentSuccess( + config: ContentSuccessConfig, + slug: string, + currentSlug?: string, +) { + if (currentSlug && currentSlug !== slug) { + revalidatePath(config.itemBasePath + "/" + currentSlug); + } + revalidatePath(config.itemBasePath + "/" + slug); + for (const listPath of config.listPaths) { + revalidatePath(listPath.path, listPath.type); + } + revalidatePath("/"); + + const redirectTarget = config.redirectTo + ? config.redirectTo(slug) + : config.itemBasePath + "/" + slug; + redirect(redirectTarget); +} + +export function createGenericActions< + TData, + TIndexValue, + TKey extends Key, + TFormState extends ContentFormState, + TParsed, +>( + editorConfig: EditorContentConfig< + TData, + TIndexValue, + TKey, + TFormState, + TParsed + >, +) { + const { contentConfig, successConfig, label } = editorConfig; + + async function create( + _prevState: TFormState | null, + formData: FormData, + ): Promise { + const email = await authenticateUser(); + if (!email) { + return { message: "Authentication required" } as TFormState; + } + + const contentDirectory = getContentDirectory(); + + const parseResult = editorConfig.parseFormData(formData); + if (!parseResult.success) { + return parseResult.state; + } + + const { parsed } = parseResult; + const { slug, data } = await editorConfig.buildCreateData( + parsed, + contentDirectory, + ); + const uploads = editorConfig.buildCreateUploads + ? await editorConfig.buildCreateUploads(parsed, contentDirectory) + : undefined; + + try { + await createContent({ + config: contentConfig, + slug, + data, + contentDirectory, + author: { name: email, email }, + commitMessage: `Add new ${label}: ${slug}`, + uploads, + }); + } catch (e) { + if (e instanceof SlugConflictError) { + return { + message: `A ${label} with slug "${e.slug}" already exists.`, + slugConflict: e.slug, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + return { + message: `Failed to create ${label}`, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + + handleContentSuccess(successConfig, slug); + + return { message: `${label} creation successful!` } as TFormState; + } + + async function overwriteCreate( + _prevState: TFormState | null, + formData: FormData, + ): Promise { + const email = await authenticateUser(); + if (!email) { + return { message: "Authentication required" } as TFormState; + } + + const contentDirectory = getContentDirectory(); + + const parseResult = editorConfig.parseFormData(formData); + if (!parseResult.success) { + return parseResult.state; + } + + const { parsed } = parseResult; + const { slug, data } = await editorConfig.buildCreateData( + parsed, + contentDirectory, + ); + const uploads = editorConfig.buildCreateUploads + ? await editorConfig.buildCreateUploads(parsed, contentDirectory) + : undefined; + + if (editorConfig.deleteConflictingContent) { + await editorConfig.deleteConflictingContent( + slug, + contentDirectory, + email, + ); + } + + try { + await createContent({ + config: contentConfig, + slug, + data, + contentDirectory, + author: { name: email, email }, + commitMessage: `Add new ${label}: ${slug}`, + uploads, + }); + } catch { + return { + message: `Failed to create ${label}`, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + + handleContentSuccess(successConfig, slug); + + return { message: `${label} creation successful!` } as TFormState; + } + + async function update( + currentDate: number, + currentSlug: string, + _prevState: TFormState | null, + formData: FormData, + ): Promise { + const email = await authenticateUser(); + if (!email) { + return { message: "Authentication required" } as TFormState; + } + + const contentDirectory = getContentDirectory(); + + const parseResult = editorConfig.parseFormData(formData); + if (!parseResult.success) { + return parseResult.state; + } + + const { parsed } = parseResult; + const { slug, data } = await editorConfig.buildUpdateData( + parsed, + currentSlug, + currentDate, + contentDirectory, + ); + const uploads = editorConfig.buildUpdateUploads + ? await editorConfig.buildUpdateUploads( + parsed, + currentSlug, + contentDirectory, + ) + : undefined; + + // Check for slug conflict when renaming + if (slug !== currentSlug && editorConfig.checkSlugConflict) { + const hasConflict = await editorConfig.checkSlugConflict( + slug, + contentDirectory, + ); + if (hasConflict) { + return { + message: `A ${label} with slug "${slug}" already exists.`, + slugConflict: slug, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + } + + const currentIndexKey = editorConfig.buildCurrentIndexKey( + currentDate, + currentSlug, + ); + + try { + await updateContent({ + config: contentConfig, + slug, + currentSlug, + currentIndexKey, + data, + contentDirectory, + author: { name: email, email }, + commitMessage: `Update ${label}: ${slug}`, + uploads, + }); + } catch { + return { + message: `Failed to update ${label}`, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + + handleContentSuccess(successConfig, slug, currentSlug); + + return { message: `${label} update successful!` } as TFormState; + } + + async function overwriteUpdate( + currentDate: number, + currentSlug: string, + _prevState: TFormState | null, + formData: FormData, + ): Promise { + const email = await authenticateUser(); + if (!email) { + return { message: "Authentication required" } as TFormState; + } + + const contentDirectory = getContentDirectory(); + + const parseResult = editorConfig.parseFormData(formData); + if (!parseResult.success) { + return parseResult.state; + } + + const { parsed } = parseResult; + const { slug, data } = await editorConfig.buildUpdateData( + parsed, + currentSlug, + currentDate, + contentDirectory, + ); + const uploads = editorConfig.buildUpdateUploads + ? await editorConfig.buildUpdateUploads( + parsed, + currentSlug, + contentDirectory, + ) + : undefined; + + // Delete conflicting content at target slug + if (slug !== currentSlug && editorConfig.deleteConflictingContent) { + await editorConfig.deleteConflictingContent( + slug, + contentDirectory, + email, + ); + } + + const currentIndexKey = editorConfig.buildCurrentIndexKey( + currentDate, + currentSlug, + ); + + try { + await updateContent({ + config: contentConfig, + slug, + currentSlug, + currentIndexKey, + data, + contentDirectory, + author: { name: email, email }, + commitMessage: `Update ${label}: ${slug}`, + uploads, + }); + } catch { + return { + message: `Failed to update ${label}`, + formData: editorConfig.extractFormData?.(parsed), + } as TFormState; + } + + handleContentSuccess(successConfig, slug, currentSlug); + + return { message: `${label} update successful!` } as TFormState; + } + + async function deleteContent_(date: number, slug: string): Promise { + const email = await authenticateUser(); + if (!email) { + throw new Error("Authentication required"); + } + + const contentDirectory = getContentDirectory(); + const indexKey = editorConfig.buildCurrentIndexKey(date, slug); + + await deleteContent({ + config: contentConfig, + slug, + indexKey, + contentDirectory, + author: { name: email, email }, + commitMessage: `Delete ${label}: ${slug}`, + }); + + const deleteConfig = editorConfig.deleteSuccessConfig || successConfig; + handleContentSuccess(deleteConfig, slug); + } + + return { + create, + overwriteCreate, + update, + overwriteUpdate, + delete: deleteContent_, + }; +} diff --git a/websites/recipe-website/editor/controller/actions/index.ts b/websites/recipe-website/editor/controller/actions/index.ts index a8b8e518..20f414ae 100644 --- a/websites/recipe-website/editor/controller/actions/index.ts +++ b/websites/recipe-website/editor/controller/actions/index.ts @@ -2,82 +2,59 @@ import { auth } from "@/auth"; import slugify from "@sindresorhus/slugify"; -import { createContent } from "content-engine/content/createContent"; import { deleteContent } from "content-engine/content/deleteContent"; import { rebuildIndex } from "content-engine/content/rebuildIndex"; -import { updateContent } from "content-engine/content/updateContent"; +import type { UploadSpec } from "content-engine/content/types"; import { getContentDirectory } from "content-engine/fs/getContentDirectory"; import { directoryIsGitRepo } from "content-engine/git/commit"; import { writeFile } from "fs-extra"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; import { join } from "node:path"; import createDefaultSlug from "recipe-website-common/controller/createSlug"; import { getRecipeBySlug } from "recipe-website-common/controller/data/read"; -import { RecipeFormState } from "recipe-website-common/controller/formState"; +import type { + RecipeFormData, + RecipeFormState, +} from "recipe-website-common/controller/formState"; import { recipeContentConfig } from "recipe-website-common/controller/recipeContentConfig"; -import { Recipe, RecipeEntryKey } from "recipe-website-common/controller/types"; +import type { + Recipe, + RecipeEntryKey, +} from "recipe-website-common/controller/types"; import simpleGit, { SimpleGit } from "simple-git"; import { z } from "zod"; -import parseRecipeFormData from "../parseFormData"; +import parseRecipeFormData, { ParsedRecipeFormData } from "../parseFormData"; +import type { EditorContentConfig } from "./editorContentConfig"; +import { createGenericActions } from "./genericActions"; const INITIAL_COMMIT_MESSAGE = "Initial commit"; -const remoteSchema = z.object({ - remoteName: z.string().min(1, "Remote Name is required"), - remoteUrl: z.string().min(1, "Remote URL is required"), -}); - -// Function to handle success actions like revalidating and redirecting -function handleSuccess(slug: string, currentSlug?: string) { - if (currentSlug && currentSlug !== slug) { - revalidatePath("/recipe/" + currentSlug); - } - revalidatePath("/recipe/" + slug); - revalidatePath("/recipes"); - revalidatePath("/recipes/[page]", "page"); - revalidatePath("/"); - redirect("/recipe/" + slug); -} - -export async function rebuildRecipeIndex() { - const contentDirectory = getContentDirectory(); - await rebuildIndex({ - config: recipeContentConfig, - contentDirectory, - }); - revalidatePath("/"); +function formDataFromParsed(parsed: ParsedRecipeFormData): RecipeFormData { + return { + name: parsed.name, + description: parsed.description, + slug: parsed.slug, + date: parsed.date || undefined, + ingredients: parsed.ingredients, + instructions: parsed.instructions, + timelines: parsed.timelines, + prepTime: parsed.prepTime, + cookTime: parsed.cookTime, + totalTime: parsed.totalTime, + recipeYield: parsed.recipeYield, + videoUrl: parsed.videoUrl || undefined, + }; } -export async function updateRecipe( - currentDate: number, - currentSlug: string, - _prevState: RecipeFormState | null, - formData: FormData, -): Promise { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - return { message: "Authentication required" }; - } - const { - user: { email }, - } = session; - - const contentDirectory = getContentDirectory(); - - const formResult = parseRecipeFormData(formData); - - if (!formResult.success) { - return { - errors: z.flattenError(formResult.error).fieldErrors, - message: "Failed to update Recipe.", - }; - } - +function buildRecipeData( + parsed: ParsedRecipeFormData, + date: number, + currentRecipeData?: Recipe | null, +): { + data: Recipe; + uploads: Record; +} { const { - date, - slug, name, description, ingredients, @@ -88,38 +65,31 @@ export async function updateRecipe( clearVideo, videoUrl, videoImportUrl, + imageImportUrl, prepTime, cookTime, totalTime, recipeYield, timelines, - } = formResult.data; - - const currentRecipeData = await getRecipeBySlug({ - slug: currentSlug, - contentDirectory, - }); - - const finalSlug = slugify(slug || createDefaultSlug(formResult.data)); - const finalDate = date || currentDate || Date.now(); + } = parsed; // Determine final video value with priority handling const videoValue = video && video.size > 0 - ? undefined // File upload - let uploads spec handle it + ? undefined : videoUrl - ? videoUrl // Direct URL entry + ? videoUrl : videoImportUrl - ? videoImportUrl // Import URL + ? videoImportUrl : clearVideo - ? undefined // Clear existing - : currentRecipeData?.video; // Keep existing + ? undefined + : currentRecipeData?.video; - // Build uploads spec - content-engine will resolve these to FileUploadData - const uploads = { + const uploads: Record = { image: { - file: image, + file: image ?? undefined, clearFile: clearImage, + fileImportUrl: imageImportUrl, existingFile: currentRecipeData?.image, }, video: { @@ -132,13 +102,14 @@ export async function updateRecipe( }, }; - // Determine final filenames based on upload specs const imageFileName = image && image.size > 0 ? image.name : clearImage ? undefined - : currentRecipeData?.image; + : imageImportUrl + ? new URL(imageImportUrl).pathname.split("/").pop() + : currentRecipeData?.image; const videoFileName = video && video.size > 0 ? video.name : videoValue; const data: Recipe = { @@ -148,7 +119,7 @@ export async function updateRecipe( instructions, image: imageFileName, video: videoFileName, - date: finalDate, + date, prepTime, cookTime, totalTime, @@ -156,171 +127,135 @@ export async function updateRecipe( timelines, }; - const currentIndexKey: RecipeEntryKey = [currentDate, currentSlug]; - - try { - // Update content (processes uploads, renames directories if needed, writes data file, updates index, commits) - await updateContent({ - config: recipeContentConfig, - slug: finalSlug, - currentSlug, - currentIndexKey, - data, - contentDirectory, - author: { name: email, email }, - commitMessage: `Update recipe: ${finalSlug}`, - uploads, - }); - } catch { - return { message: "Failed to update recipe" }; - } - - handleSuccess(finalSlug, currentSlug); - - return { message: "Recipe update successful!" }; + return { data, uploads }; } -// Main createRecipe function to orchestrate the process -export async function createRecipe( - _prevState: RecipeFormState | null, - formData: FormData, -): Promise { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - return { message: "Authentication required" }; - } - const { - user: { email }, - } = session; - - const contentDirectory = getContentDirectory(); - - const formResult = parseRecipeFormData(formData); - - if (!formResult.success) { - return { - errors: z.flattenError(formResult.error).fieldErrors, - message: "Error parsing recipe", - }; - } +const recipeEditorConfig: EditorContentConfig< + Recipe, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + RecipeEntryKey, + RecipeFormState, + ParsedRecipeFormData +> = { + contentConfig: recipeContentConfig, + successConfig: { + itemBasePath: "/recipe", + listPaths: [ + { path: "/recipes" }, + { path: "/recipes/[page]", type: "page" as const }, + ], + }, + deleteSuccessConfig: { + itemBasePath: "/recipe", + listPaths: [ + { path: "/recipes" }, + { path: "/recipes/[page]", type: "page" as const }, + ], + redirectTo: () => "/", + }, + label: "recipe", + + parseFormData(formData: FormData) { + const formResult = parseRecipeFormData(formData); + if (!formResult.success) { + return { + success: false as const, + state: { + errors: z.flattenError(formResult.error).fieldErrors, + message: "Error parsing recipe", + }, + }; + } + return { success: true as const, parsed: formResult.data }; + }, - const { - date: givenDate, - slug: givenSlug, - name, - description, - ingredients, - instructions, - image, - clearImage, - video, - clearVideo, - videoUrl, - videoImportUrl, - imageImportUrl, - prepTime, - cookTime, - totalTime, - recipeYield, - timelines, - } = formResult.data; + async buildCreateData(parsed) { + const date: number = parsed.date || Date.now(); + const slug = slugify(parsed.slug || createDefaultSlug(parsed)); + const { data } = buildRecipeData(parsed, date); + return { slug, data }; + }, - const date: number = givenDate || (Date.now() as number); - const slug = slugify(givenSlug || createDefaultSlug(formResult.data)); + async buildUpdateData(parsed, currentSlug, currentDate, contentDirectory) { + const currentRecipeData = await getRecipeBySlug({ + slug: currentSlug, + contentDirectory, + }); + const slug = slugify(parsed.slug || createDefaultSlug(parsed)); + const date = parsed.date || currentDate || Date.now(); + const { data } = buildRecipeData(parsed, date, currentRecipeData); + return { slug, data }; + }, - // Determine final video value with priority handling - const videoValue = - video && video.size > 0 - ? undefined // File upload - let uploads spec handle it - : videoUrl - ? videoUrl // Direct URL entry - : videoImportUrl - ? videoImportUrl // Import URL - : undefined; + async buildCreateUploads(parsed) { + const { uploads } = buildRecipeData(parsed, 0); + return uploads; + }, - // Build uploads spec - content-engine will resolve these to FileUploadData - const uploads = { - image: { - file: image, - clearFile: clearImage, - fileImportUrl: imageImportUrl, - }, - video: { - file: video && video.size > 0 ? video : undefined, - clearFile: clearVideo && !videoUrl && !videoImportUrl, - }, - }; + async buildUpdateUploads(parsed, currentSlug, contentDirectory) { + const currentRecipeData = await getRecipeBySlug({ + slug: currentSlug, + contentDirectory, + }); + const { uploads } = buildRecipeData(parsed, 0, currentRecipeData); + return uploads; + }, - // Determine final filenames based on upload specs - const imageFileName = - image && image.size > 0 - ? image.name - : imageImportUrl - ? new URL(imageImportUrl).pathname.split("/").pop() - : undefined; - const videoFileName = video && video.size > 0 ? video.name : videoValue; + buildCurrentIndexKey(currentDate, currentSlug) { + return [currentDate, currentSlug]; + }, - const data: Recipe = { - name, - description, - ingredients, - instructions, - image: imageFileName, - video: videoFileName, - date, - prepTime, - cookTime, - totalTime, - recipeYield, - timelines, - }; + extractFormData: formDataFromParsed, - try { - // Create content (processes uploads, writes data file, updates index, commits) - await createContent({ - config: recipeContentConfig, - slug, - data, - contentDirectory, - author: { name: email, email }, - commitMessage: `Add new recipe: ${slug}`, - uploads, - }); - } catch { - return { message: "Failed to create recipe" }; - } + async checkSlugConflict(slug, contentDirectory) { + try { + const existing = await getRecipeBySlug({ slug, contentDirectory }); + return !!existing; + } catch { + return false; + } + }, - handleSuccess(slug); + async deleteConflictingContent(slug, contentDirectory, email) { + try { + const existingRecipe = await getRecipeBySlug({ slug, contentDirectory }); + if (existingRecipe) { + const indexKey: RecipeEntryKey = [existingRecipe.date, slug]; + await deleteContent({ + config: recipeContentConfig, + slug, + indexKey, + contentDirectory, + author: { name: email, email }, + commitMessage: `Delete recipe before overwrite: ${slug}`, + }); + } + } catch { + // Recipe doesn't exist at target slug — nothing to delete + } + }, +}; - return { message: "Recipe creation successful!" }; -} +const recipeActions = createGenericActions(recipeEditorConfig); +export const createRecipe = recipeActions.create; +export const overwriteRecipe = recipeActions.overwriteCreate; +export const updateRecipe = recipeActions.update; +export const overwriteUpdateRecipe = recipeActions.overwriteUpdate; +export const deleteRecipe = recipeActions.delete; -export async function deleteRecipe(date: number, slug: string) { - // Auth check - const session = await auth(); - if (!session?.user?.email) { - throw new Error("Authentication required"); - } - const { - user: { email }, - } = session; +const remoteSchema = z.object({ + remoteName: z.string().min(1, "Remote Name is required"), + remoteUrl: z.string().min(1, "Remote URL is required"), +}); +export async function rebuildRecipeIndex() { const contentDirectory = getContentDirectory(); - const indexKey: RecipeEntryKey = [date, slug]; - - await deleteContent({ + await rebuildIndex({ config: recipeContentConfig, - slug, - indexKey, contentDirectory, - author: { name: email, email }, - commitMessage: `Delete recipe: ${slug}`, }); - - revalidatePath("/recipe/" + slug); revalidatePath("/"); - redirect("/"); } export async function createRemote( diff --git a/websites/recipe-website/editor/controller/actions/shared.ts b/websites/recipe-website/editor/controller/actions/shared.ts new file mode 100644 index 00000000..668c695e --- /dev/null +++ b/websites/recipe-website/editor/controller/actions/shared.ts @@ -0,0 +1,9 @@ +import { auth } from "@/auth"; + +export async function authenticateUser(): Promise { + const session = await auth(); + if (!session?.user?.email) { + return null; + } + return session.user.email; +} diff --git a/websites/recipe-website/editor/controller/parseFormData.ts b/websites/recipe-website/editor/controller/parseFormData.ts index a5f9ad9b..fd3b0fdd 100644 --- a/websites/recipe-website/editor/controller/parseFormData.ts +++ b/websites/recipe-website/editor/controller/parseFormData.ts @@ -124,6 +124,7 @@ const RecipeFormSchema = z.object({ }), ) .optional(), + action: z.enum(["overwrite"]).optional(), }); export type ParsedRecipeFormData = z.infer; diff --git a/websites/recipe-website/editor/cypress/e2e/edit-duplicate-slug.cy.ts b/websites/recipe-website/editor/cypress/e2e/edit-duplicate-slug.cy.ts new file mode 100644 index 00000000..3f0e6b2c --- /dev/null +++ b/websites/recipe-website/editor/cypress/e2e/edit-duplicate-slug.cy.ts @@ -0,0 +1,48 @@ +describe("Edit Recipe Duplicate Slug Detection", function () { + beforeEach(function () { + cy.resetData("two-pages"); + cy.visit("/recipe/recipe-6/edit"); + cy.fillSignInForm(); + cy.findByText("Editing Recipe: Recipe 6"); + }); + + it("should show an error when changing slug to an existing recipe's slug", function () { + cy.findByText("Advanced").click(); + cy.findByLabelText("Slug").clear({ force: true }); + cy.findByLabelText("Slug").type("recipe-5", { force: true }); + cy.findByText("Submit").click(); + + cy.findByText(/already exists/i); + cy.findByRole("button", { name: "Overwrite" }); + }); + + it("should overwrite an existing recipe when clicking Overwrite", function () { + const editedName = "Recipe 6 Renamed"; + cy.findAllByLabelText("Name").first().clear(); + cy.findAllByLabelText("Name").first().type(editedName); + + cy.findByText("Advanced").click(); + cy.findByLabelText("Slug").clear({ force: true }); + cy.findByLabelText("Slug").type("recipe-5", { force: true }); + cy.findByText("Submit").click(); + + // Wait for duplicate error to appear + cy.findByText(/already exists/i); + cy.findByRole("button", { name: "Overwrite" }).click(); + + // Should redirect to the recipe page with the new slug + cy.url().should("include", "/recipe/recipe-5"); + cy.findByText(editedName, { selector: "h1" }); + }); + + it("should successfully edit a recipe with a unique slug (no conflict)", function () { + cy.findByText("Advanced").click(); + cy.findByLabelText("Slug").clear({ force: true }); + cy.findByLabelText("Slug").type("recipe-6-unique", { force: true }); + cy.findByText("Submit").click(); + + cy.url().should("include", "/recipe/recipe-6-unique"); + cy.findByText("Recipe 6", { selector: "h1" }); + cy.findByRole("button", { name: "Overwrite" }).should("not.exist"); + }); +}); diff --git a/websites/recipe-website/editor/cypress/e2e/new-recipe-duplicate-slug.cy.ts b/websites/recipe-website/editor/cypress/e2e/new-recipe-duplicate-slug.cy.ts new file mode 100644 index 00000000..d7b8fe4c --- /dev/null +++ b/websites/recipe-website/editor/cypress/e2e/new-recipe-duplicate-slug.cy.ts @@ -0,0 +1,49 @@ +describe("New Recipe Duplicate Slug Detection", function () { + beforeEach(function () { + cy.resetData("one-recipe"); + cy.visit("/new-recipe"); + cy.fillSignInForm(); + }); + + it("should show an error when submitting a recipe with a duplicate slug (auto-generated)", function () { + // "Existing Recipe" auto-generates slug "existing-recipe" which already exists + cy.get('[name="name"]').type("Existing Recipe"); + cy.findByText("Submit").click(); + + cy.findByText(/already exists/i); + cy.findByRole("button", { name: "Overwrite" }); + }); + + it("should show an error when submitting a recipe with a manually entered duplicate slug", function () { + cy.get('[name="name"]').type("Something Different"); + cy.findByText("Advanced").click(); + cy.findByLabelText("Slug").clear({ force: true }); + cy.findByLabelText("Slug").type("existing-recipe", { force: true }); + cy.findByText("Submit").click(); + + cy.findByText(/already exists/i); + cy.findByRole("button", { name: "Overwrite" }); + }); + + it("should overwrite an existing recipe when clicking Overwrite", function () { + cy.get('[name="name"]').type("Existing Recipe"); + cy.findByText("Submit").click(); + + // Wait for duplicate error to appear + cy.findByText(/already exists/i); + cy.findByRole("button", { name: "Overwrite" }).click(); + + // Should redirect to the recipe page + cy.url().should("include", "/recipe/existing-recipe"); + cy.findByText("Existing Recipe", { selector: "h1" }); + }); + + it("should successfully create a recipe with a unique slug (no error)", function () { + cy.get('[name="name"]').type("Brand New Recipe"); + cy.findByText("Submit").click(); + + cy.url().should("include", "/recipe/brand-new-recipe"); + cy.findByText("Brand New Recipe", { selector: "h1" }); + cy.findByRole("button", { name: "Overwrite" }).should("not.exist"); + }); +}); diff --git a/websites/recipe-website/editor/src/app/(recipes)/new-recipe/form.tsx b/websites/recipe-website/editor/src/app/(recipes)/new-recipe/form.tsx index d048c26d..06b13fc6 100644 --- a/websites/recipe-website/editor/src/app/(recipes)/new-recipe/form.tsx +++ b/websites/recipe-website/editor/src/app/(recipes)/new-recipe/form.tsx @@ -4,7 +4,10 @@ import CreateRecipeFields from "recipe-website-common/components/Form/Create"; import { useActionState } from "react"; import { SubmitButton } from "component-library/components/SubmitButton"; import { RecipeFormState } from "recipe-website-common/controller/formState"; -import { createRecipe } from "recipe-editor/controller/actions"; +import { + createRecipe, + overwriteRecipe, +} from "recipe-editor/controller/actions"; import { importRecipeAction } from "./actions"; import { TextInput } from "component-library/components/Form/inputs/Text"; import { RecipeActionState } from "./common"; @@ -28,6 +31,7 @@ export default function NewOrImportRecipeForm({ createRecipe, initialSubmissionState, ); + const [, overwriteDispatch] = useActionState(overwriteRecipe, null); return (
@@ -40,10 +44,10 @@ export default function NewOrImportRecipeForm({

New Recipe

{submissionState.message && ( @@ -52,8 +56,16 @@ export default function NewOrImportRecipeForm({

)}
-
+
Submit + {submissionState.slugConflict && ( + + )}
diff --git a/websites/recipe-website/editor/src/app/(recipes)/recipe/[slug]/edit/form.tsx b/websites/recipe-website/editor/src/app/(recipes)/recipe/[slug]/edit/form.tsx index 31a13358..5d26d0c9 100644 --- a/websites/recipe-website/editor/src/app/(recipes)/recipe/[slug]/edit/form.tsx +++ b/websites/recipe-website/editor/src/app/(recipes)/recipe/[slug]/edit/form.tsx @@ -6,7 +6,10 @@ import { SubmitButton } from "component-library/components/SubmitButton"; import { Recipe } from "recipe-website-common/controller/types"; import { RecipeFormState } from "recipe-website-common/controller/formState"; import { StaticImageProps } from "next-static-image/src"; -import { updateRecipe } from "../../../../../../controller/actions"; +import { + updateRecipe, + overwriteUpdateRecipe, +} from "../../../../../../controller/actions"; export default function EditRecipeForm({ recipe, @@ -21,6 +24,12 @@ export default function EditRecipeForm({ const initialState = { message: "", errors: {} } as RecipeFormState; const updateThisRecipe = updateRecipe.bind(null, date, slug); const [state, dispatch] = useActionState(updateThisRecipe, initialState); + const overwriteThisRecipe = overwriteUpdateRecipe.bind(null, date, slug); + const [, overwriteDispatch] = useActionState(overwriteThisRecipe, null); + const effectiveRecipe = state.formData + ? { ...recipe, ...state.formData } + : recipe; + return (
-
+
+ {state.message && ( +

{state.message}

+ )} +
+
Submit + {state.slugConflict && ( + + )}
);