diff --git a/packages/next-static-image/src/index.tsx b/packages/next-static-image/src/index.tsx index d7680cc..2741a90 100644 --- a/packages/next-static-image/src/index.tsx +++ b/packages/next-static-image/src/index.tsx @@ -14,7 +14,7 @@ export interface StaticImageProps { props: ImgProps; } -export interface TransformedRecipeImageProps { +export interface TransformedStaticImageProps { slug: string; image: string; alt: string; diff --git a/packages/next-static-image/src/resizeImage.ts b/packages/next-static-image/src/resizeImage.ts index 19577fc..5c04e6c 100644 --- a/packages/next-static-image/src/resizeImage.ts +++ b/packages/next-static-image/src/resizeImage.ts @@ -39,10 +39,20 @@ async function resizeImage({ const { dir } = parse(resultPath); await ensureDir(dir); - await oraPromise( - sharp.resize({ width }).webp({ quality }).toFile(resultPath), - `Resizing ${resultFilename}`, - ); + // Get the original image metadata + const metadata = await sharp.metadata(); + const originalWidth = metadata.width; + + // Check if the target width is larger than the original width + if (originalWidth === undefined || width <= originalWidth) { + await oraPromise( + sharp.resize({ width }).webp({ quality }).toFile(resultPath), + `Resizing ${resultFilename}`, + ); + } else { + // If the target width is larger than the original width, skip resizing + await sharp.toFile(resultPath); + } // Release our spot in cache for currently running transforms. runningResizes.delete(resultPath); @@ -51,11 +61,11 @@ async function resizeImage({ export async function queuePossibleImageResize(props: ImageResizeProps) { const { resultPath } = props; // If this same transform is currently running, await it and return. - const currentlyRunningTransform = runningResizes.get(resultPath); - if (currentlyRunningTransform) { - await currentlyRunningTransform; - return; - } + // const currentlyRunningTransform = runningResizes.get(resultPath); + // if (currentlyRunningTransform) { + // await currentlyRunningTransform; + // return; + // } // Otherwise, claim our spot in cache and start the transform. const resizePromise = resizeImage(props); diff --git a/websites/portfolio/editor/cypress.config.ts b/websites/portfolio/editor/cypress.config.ts deleted file mode 100644 index cc45892..0000000 --- a/websites/portfolio/editor/cypress.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from "cypress"; -import { resolve } from "node:path"; -import { copy, remove } from "fs-extra"; - -export default defineConfig({ - e2e: { - baseUrl: "http://localhost:3000", - setupNodeEvents(on) { - on("task", { - async resetData(fixture?: string) { - await remove(resolve("test-content")); - if (fixture) { - await copy( - resolve("cypress", "fixtures", "test-content", fixture), - resolve("test-content"), - ); - } - await copy( - resolve("cypress", "fixtures", "users"), - resolve("test-content", "users"), - ); - return null; - }, - }); - }, - retries: { - runMode: 2, - }, - }, -}); diff --git a/websites/portfolio/editor/cypress/e2e/edit.cy.ts b/websites/portfolio/editor/cypress/e2e/edit.cy.ts deleted file mode 100644 index fdcd177..0000000 --- a/websites/portfolio/editor/cypress/e2e/edit.cy.ts +++ /dev/null @@ -1,199 +0,0 @@ -describe("Project Edit View", function () { - describe("with seven items", function () { - beforeEach(function () { - cy.resetData("two-pages"); - cy.visit("/project/project-6/edit"); - }); - - it("should need authorization", function () { - cy.findByText("Sign in with Credentials"); - }); - - it("should require authorization even when project doesn't exist", function () { - cy.visit({ - url: "/project/non-existent-project/edit", - }); - }); - - describe("when authenticated", function () { - beforeEach(function () { - cy.fillSignInForm(); - }); - - it("should be able to edit a project", function () { - cy.findByText("Editing Project: Project 6"); - - cy.findByText("Advanced").click(); - - const editedProjectTitle = "Edited Project"; - - cy.findAllByLabelText("Name").first().clear(); - cy.findAllByLabelText("Name").first().type(editedProjectTitle); - - const projectDate = "2023-12-08T01:16:12.622"; - cy.findByLabelText("Date (UTC)").should("have.value", projectDate); - - cy.findByText("Submit").click(); - - cy.findByText(editedProjectTitle); - - cy.visit("/"); - cy.findByText(editedProjectTitle); - cy.checkNamesInOrder([ - "Project 7", - editedProjectTitle, - "Project 5", - "Project 4", - "Project 3", - "Project 2", - ]); - - // Project date should not have changed - cy.findByText(new Date(projectDate + "Z").toLocaleString()); - }); - - it("should be able to set a project image over another image", function () { - cy.findByText("Editing Project: Project 6"); - - // Image preview should be current image - cy.findByRole("img").should( - "have.attr", - "src", - "/image/project/project-6/uploads/project-6-test-image.png/project-6-test-image-w3840q75.webp", - ); - - cy.findByLabelText("Image").selectFile({ - contents: - "cypress/fixtures/images/project-6-test-image-alternate.png", - fileName: "project-6-test-image-alternate.png", - mimeType: "image/png", - }); - - // Image preview should now be blob from pending image - cy.findByRole("img") - .should("have.attr", "src") - .should("match", /^blob:/); - - cy.findByText("Submit").click(); - - // Image on view page should be alternate - cy.findByRole("img").should( - "have.attr", - "src", - "/image/project/project-6/uploads/project-6-test-image-alternate.png/project-6-test-image-alternate-w3840q75.webp", - ); - - // Image on index should be alternate - cy.visit("/"); - cy.findByText("Project 6") - .parentsUntil("li") - .findByRole("img") - .should( - "have.attr", - "src", - "/image/project/project-6/uploads/project-6-test-image-alternate.png/project-6-test-image-alternate-w828q75.webp", - ); - }); - - it("should be able to set a project image on a project without an image", function () { - cy.visit("/project/project-5/edit"); - cy.findByText("Editing Project: Project 5"); - - // With no image, the preview and "remove image" checkbox should not be present - cy.findAllByRole("img").should("not.exist"); - cy.findAllByLabelText("Remove Image").should("not.exist"); - - cy.findByLabelText("Image").selectFile({ - contents: - "cypress/fixtures/images/project-6-test-image-alternate.png", - fileName: "project-6-test-image-alternate.png", - mimeType: "image/png", - }); - - // Image preview should now be blob from pending image - cy.findByRole("img", { timeout: 10000 }) - .should("have.attr", "src") - .should("match", /^blob:/); - - cy.findAllByLabelText("Remove Image").should("not.exist"); - - cy.findByText("Submit").click(); - - cy.findByText("Project 5"); - // Image on view page should be alternate - cy.findByRole("img").should( - "have.attr", - "src", - "/image/project/project-5/uploads/project-6-test-image-alternate.png/project-6-test-image-alternate-w3840q75.webp", - ); - - // Image on index should be alternate - cy.visit("/"); - cy.findByText("Project 5") - .parentsUntil("li") - .findByRole("img") - .should( - "have.attr", - "src", - "/image/project/project-5/uploads/project-6-test-image-alternate.png/project-6-test-image-alternate-w828q75.webp", - ); - }); - - it("should be able to remove an image", function () { - cy.findByText("Editing Project: Project 6"); - - cy.findByRole("img"); - - cy.findByLabelText("Remove Image").click(); - - cy.findByText("Submit").click(); - - cy.findByText("Edit").click(); - - // With no image, the preview and "remove image" checkbox should not be present - cy.findAllByRole("img").should("not.exist"); - cy.findAllByLabelText("Remove Image").should("not.exist"); - }); - - it("should be able to preserve an image when editing", function () { - cy.findByText("Editing Project: Project 6"); - - cy.findByRole("img"); - - const editedProjectTitle = "Edited Project"; - - cy.findAllByLabelText("Name").first().clear(); - cy.findAllByLabelText("Name").first().type(editedProjectTitle); - - cy.findByText("Submit").click(); - - cy.findByText(editedProjectTitle); - cy.findByRole("img").should( - "have.attr", - "src", - "/image/project/project-6/uploads/project-6-test-image.png/project-6-test-image-w3840q75.webp", - ); - - cy.findByText("Edit").click(); - - cy.findByText("Editing Project: Edited Project"); - - cy.findByRole("img").should( - "have.attr", - "src", - "/image/project/project-6/uploads/project-6-test-image.png/project-6-test-image-w3840q75.webp", - ); - cy.findByLabelText("Remove Image"); - }); - - it("should have status 404 when project doesn't exist", function () { - cy.request({ - url: "/project/non-existent-project/edit", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - }); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/homepage.cy.ts b/websites/portfolio/editor/cypress/e2e/homepage.cy.ts deleted file mode 100644 index 387f3a0..0000000 --- a/websites/portfolio/editor/cypress/e2e/homepage.cy.ts +++ /dev/null @@ -1,98 +0,0 @@ -describe("Index Page", function () { - describe("when empty", function () { - beforeEach(function () { - cy.resetData(); - cy.visit("/"); - }); - - it("should not need authorization", function () { - cy.findByText("Sign In"); - }); - - it("should inform the user if there are no projects", function () { - cy.findByText("There are no projects yet."); - }); - - it("should be able to create and delete a project", function () { - const testProject = "Test Project"; - - // We should start with no projects - cy.findByText("There are no projects yet."); - cy.findAllByText(testProject).should("not.exist"); - - cy.findByText("New Project").click(); - - cy.fillSignInForm(); - - cy.findByLabelText("Name").type(testProject); - cy.findByText("Submit").click(); - cy.findByText(testProject); - - // Check home and ensure the project is present - cy.visit("/"); - cy.findByText(testProject).click(); - - // Delete the project and ensure it's gone - cy.findByText("Delete").click(); - cy.findAllByText(testProject).should("not.exist"); - - cy.request({ - url: "/project/test-page", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - }); - - it("should be able to create projects and see them in chronological order", function () { - cy.findByText("Sign In").click(); - - cy.fillSignInForm(); - - const testNames = ["c", "a", "1"].map((x) => `Project ${x}`); - for (const testProject of testNames) { - cy.visit("/new-project"); - cy.findByLabelText("Name").type(testProject); - cy.findByText("Submit").click(); - cy.findByText(testProject); - } - - cy.visit("/"); - cy.checkNamesInOrder(testNames.reverse()); - }); - }); - - describe("with just enough items for the front page", function () { - beforeEach(function () { - cy.resetData("front-page-only"); - cy.visit("/"); - }); - - it("should not display a link to the index", function () { - const allNames = [3, 2, 1].map((x) => `Project ${x}`); - - // Homepage should have latest three projects - cy.checkNamesInOrder(allNames); - - cy.findAllByText("More").should("not.exist"); - }); - }); - - describe("with seven items", function () { - beforeEach(function () { - cy.resetData("two-pages"); - cy.visit("/"); - }); - - it("should display the latest six projects", function () { - const allNames = [7, 6, 5, 4, 3, 2, 1].map((x) => `Project ${x}`); - - // Homepage should have latest three projects - cy.checkNamesInOrder(allNames.slice(0, 6)); - - // First page should have all projects - cy.findByText("More").click(); - cy.checkNamesInOrder(allNames.slice(0, 7)); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/menus.cy.ts b/websites/portfolio/editor/cypress/e2e/menus.cy.ts deleted file mode 100644 index 3238502..0000000 --- a/websites/portfolio/editor/cypress/e2e/menus.cy.ts +++ /dev/null @@ -1,103 +0,0 @@ -describe("Menu Editor", function () { - describe("with a clean slate", function () { - beforeEach(function () { - cy.resetData(); - cy.visit("/menus"); - }); - - it("should need authorization", function () { - cy.findByText("Sign in with Credentials"); - }); - - it("should need authorization when directly going to edit the header", function () { - cy.visit("/menus/edit/header"); - cy.findByText("Sign in with Credentials"); - }); - - describe("when authenticated", function () { - beforeEach(function () { - cy.fillSignInForm(); - }); - - it("should be able to add to, edit, and clear the header nav", function () { - // Add nav item to header - - cy.findByText("Header").click(); - cy.findByText("Append").click(); - cy.findByLabelText("Name").type("About", { force: true }); - cy.findByLabelText("Href").type("/about", { force: true }); - cy.findByText("Submit").click(); - - // Verify new header nav - - cy.findByText("Menu Editor"); - cy.findByText("About").should("have.attr", "href", "/about"); - - // Edit header nav - - cy.findByText("Header").click(); - - cy.findByLabelText("Name").clear(); - cy.findByLabelText("Name").type("About Us", { force: true }); - cy.findByLabelText("Href").clear(); - cy.findByLabelText("Href").type("/about-us", { force: true }); - cy.findByText("Submit").click(); - - // Verify edited header nav - - cy.findByText("Menu Editor"); - cy.findByText("About Us").should("have.attr", "href", "/about-us"); - - // Clear header nav - - cy.findByText("Header").click(); - cy.findByText("Delete").click(); - - // Verify cleared header nav - - cy.findByText("Menu Editor"); - cy.findAllByText("About").should("not.exist"); - }); - - it("should be able to add to, edit, and clear the footer nav", function () { - // Add nav item to footer - - cy.findByText("Footer").click(); - cy.findByText("Append").click(); - cy.findByLabelText("Name").type("About", { force: true }); - cy.findByLabelText("Href").type("/about", { force: true }); - cy.findByText("Submit").click(); - - // Verify new footer nav - - cy.findByText("Menu Editor"); - cy.findByText("About").should("have.attr", "href", "/about"); - - // Edit footer nav - - cy.findByText("Footer").click(); - - cy.findByLabelText("Name").clear(); - cy.findByLabelText("Name").type("About Us", { force: true }); - cy.findByLabelText("Href").clear(); - cy.findByLabelText("Href").type("/about-us", { force: true }); - cy.findByText("Submit").click(); - - // Verify edited footer nav - - cy.findByText("Menu Editor"); - cy.findByText("About Us").should("have.attr", "href", "/about-us"); - - // Clear footer nav - - cy.findByText("Footer").click(); - cy.findByText("Delete").click(); - - // Verify cleared footer nav - - cy.findByText("Menu Editor"); - cy.findAllByText("About").should("not.exist"); - }); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/new-project.cy.ts b/websites/portfolio/editor/cypress/e2e/new-project.cy.ts deleted file mode 100644 index 7b7be6c..0000000 --- a/websites/portfolio/editor/cypress/e2e/new-project.cy.ts +++ /dev/null @@ -1,314 +0,0 @@ -describe("New Project View", function () { - describe("with the importable uploads fixture", function () { - beforeEach(function () { - cy.resetData("importable-uploads"); - cy.visit("/new-project"); - }); - - it("should need authentication", function () { - cy.findByText("Sign in with Credentials"); - }); - - describe("when authenticated", function () { - beforeEach(function () { - cy.fillSignInForm(); - }); - - it("should be able to create a new project", function () { - cy.findByRole("heading", { name: "New Project" }); - - const newProjectTitle = "My New Project"; - - cy.findAllByLabelText("Name").first().clear(); - cy.findAllByLabelText("Name").first().type(newProjectTitle); - - cy.findByText("Submit").click(); - - cy.findByRole("heading", { name: newProjectTitle }); - - cy.visit("/"); - - cy.findByText(newProjectTitle); - - cy.checkNamesInOrder([newProjectTitle]); - }); - - it("should be able to paste ingredients", function () { - cy.findByRole("heading", { name: "New Project" }); - - const newProjectTitle = "My New Project"; - - cy.findAllByLabelText("Name").first().clear(); - cy.findAllByLabelText("Name").first().type(newProjectTitle); - - cy.findByText("Paste Ingredients").click(); - cy.findByTitle("Ingredients Paste Area").type( - ` -1 cup water ((for the dashi packet)) -1 dashi packet -2 tsp sugar -2 Tbsp mirin -2 Tbsp soy sauce -½ onion ((4 oz 113 g)) -1 green onion/scallion ((for garnish)) -3 large eggs (50 g each w/o shell) -2 tonkatsu -2 servings cooked Japanese short-grain rice ((typically 1⅔ cups (250 g) per donburi serving)) -`, - ); - - cy.findByText("Import Ingredients").click(); - - // Verify first ingredient - cy.get('[name="ingredients[0].ingredient"]').should( - "have.value", - ` cup water ((for the dashi packet))`, - ); - - // Verify vulgar fraction ingredient - cy.get('[name="ingredients[5].ingredient"]').should( - "have.value", - ` onion (( oz g))`, - ); - - cy.findByText("Submit").click(); - - cy.findByRole("heading", { name: newProjectTitle }); - - cy.findByText("1 cup water ((for the dashi packet))"); - cy.findByText("1 dashi packet"); - cy.findByText("2 tsp sugar"); - cy.findByText("2 Tbsp mirin"); - cy.findByText("2 Tbsp soy sauce"); - cy.findByText("1/2 onion ((4 oz 113 g))"); - cy.findByText("1 green onion/scallion ((for garnish))"); - cy.findByText("3 large eggs (50 g each w/o shell)"); - cy.findByText("2 tonkatsu"); - cy.findByText( - "2 servings cooked Japanese short-grain rice ((typically 4 cups (250 g) per donburi serving))", - ); - }); - - it("should be able to import a project", function () { - const baseURL = Cypress.config().baseUrl; - const testURL = "/uploads/katsudon.html"; - const fullTestURL = new URL(testURL, baseURL); - cy.findByLabelText("Import from URL").type(fullTestURL.href); - cy.findByRole("button", { name: "Import" }).click(); - cy.url().should( - "equal", - new URL( - "/new-project?import=http%3A%2F%2Flocalhost%3A3000%2Fuploads%2Fkatsudon.html", - baseURL, - ).href, - ); - - // Stay within the project form to minimize matching outside - cy.get("#project-form").within(() => { - // Verify top-level fields, i.e. name and description - cy.get('[name="name"]').should("have.value", "Katsudon"); - cy.get('[name="description"]').should( - "have.value", - "*Imported from [http://localhost:3000/uploads/katsudon.html](http://localhost:3000/uploads/katsudon.html)*\n\n---\n\nKatsudon is a Japanese pork cutlet rice bowl made with tonkatsu, eggs, and sautéed onions simmered in a sweet and savory sauce. It‘s a one-bowl wonder and true comfort food!", - ); - - // Verify first ingredient - cy.get('[name="ingredients[0].ingredient"]').should( - "have.value", - ` cup water ((for the dashi packet))`, - ); - - // Verify vulgar fraction ingredient - cy.get('[name="ingredients[5].ingredient"]').should( - "have.value", - ` onion (( oz, g))`, - ); - - // Verify first instruction, which is a simple step - cy.get('[name="instructions[0].type"]').should("have.value", "step"); - cy.get('[name="instructions[0].name"]').should("have.value", ""); - cy.get('[name="instructions[0].text"]').should( - "have.value", - "Before You Start: Gather all the ingredients. For the steamed rice, please note that 1½ cups (300 g, 2 rice cooker cups) of uncooked Japanese short-grain rice yield 4⅓ cups (660 g) of cooked rice, enough for 2 donburi servings (3⅓ cups, 500 g). See how to cook short-grain rice with a rice cooker, pot over the stove, Instant Pot, or donabe.", - ); - - // Verify second instruction, which is a group - cy.get('[name="instructions[1].type"]').should("have.value", "group"); - cy.get('[name="instructions[1].name"]').should( - "have.value", - "To Make the Dashi", - ); - cy.get('[name="instructions[1].text"]').should("not.exist"); - }); - }); - - it("should be able to import a project with an image", function () { - const baseURL = Cypress.config().baseUrl; - const testURL = "/uploads/blackstone-nachos.html"; - const fullTestURL = new URL(testURL, baseURL); - cy.findByLabelText("Import from URL").type(fullTestURL.href); - cy.findByRole("button", { name: "Import" }).click(); - cy.url().should( - "equal", - new URL( - "/new-project?import=http%3A%2F%2Flocalhost%3A3000%2Fuploads%2Fblackstone-nachos.html", - baseURL, - ).href, - ); - - // Stay within the project form to minimize matching outside - cy.get("#project-form").within(() => { - // Verify top-level fields, i.e. name and description - cy.get('[name="name"]').should( - "have.value", - "Blackstone Griddle Grilled Nachos", - ); - cy.get('[name="description"]').should( - "have.value", - "*Imported from [http://localhost:3000/uploads/blackstone-nachos.html](http://localhost:3000/uploads/blackstone-nachos.html)*\n\n---\n\nWho doesn’t love nachos? Jazz up your nacho routine with these super-tasty Blackstone Nachos Supreme. Made effortlessly on your Blackstone Griddle, there’s nothing like this towering pile of crispy chips and delish toppings for your next snack attack.", - ); - - // Verify first ingredient - cy.get('[name="ingredients[0].ingredient"]').should( - "have.value", - `Olive Oil tablespoon`, - ); - - // Verify last ingredient - cy.get('[name="ingredients[9].ingredient"]').should( - "have.value", - `Lettuce, Shredded cup`, - ); - - // Verify empty string ingredient from import was ignored - cy.get('[name="ingredients[10].ingredient"]').should("not.exist"); - - // Verify first instruction - cy.get('[name="instructions[0].type"]').should("have.value", "step"); - cy.get('[name="instructions[0].name"]').should("have.value", ""); - cy.get('[name="instructions[0].text"]').should( - "have.value", - "Preheat the Blackstone Flat Top Griddle to medium heat.", - ); - - // Image preview should be external link to image we will import - cy.findByRole("img").should( - "have.attr", - "src", - new URL("/uploads/2021-11-28_0107-scaled-720x720.png", baseURL) - .href, - ); - }); - - cy.findByText("Submit").click(); - - // Ensure we're on the view page and not the new-project page - cy.findByLabelText("Multiply"); - - // Image should be newly created from the import's source - const processedImagePath = - "/image/project/blackstone-griddle-grilled-nachos/uploads/2021-11-28_0107-scaled-720x720.png/2021-11-28_0107-scaled-720x720-w3840q75.webp"; - - cy.findByRole("img").should("have.attr", "src", processedImagePath); - - cy.request({ - url: processedImagePath, - }) - .its("status") - .should("equal", 200); - - // Ensure resulting edit page works - - cy.findByText("Edit").click(); - - cy.findByText("Editing Project: Blackstone Griddle Grilled Nachos"); - - cy.findByRole("img").should("have.attr", "src", processedImagePath); - }); - - it("should be able to import a project with a singular image", function () { - const baseURL = Cypress.config().baseUrl; - const testURL = "/uploads/pork-carnitas.html"; - const fullTestURL = new URL(testURL, baseURL); - cy.findByLabelText("Import from URL").type(fullTestURL.href); - cy.findByRole("button", { name: "Import" }).click(); - cy.url().should( - "equal", - new URL( - "/new-project?import=http%3A%2F%2Flocalhost%3A3000%2Fuploads%2Fpork-carnitas.html", - baseURL, - ).href, - ); - - // Stay within the project form to minimize matching outside - cy.get("#project-form").within(() => { - // Verify top-level fields, i.e. name and description - cy.get('[name="name"]').should("have.value", "Pork Carnitas"); - cy.get('[name="description"]').should( - "have.value", - `*Imported from [http://localhost:3000/uploads/pork-carnitas.html](http://localhost:3000/uploads/pork-carnitas.html)* - ---- - -Carnitas, or Mexican pulled pork, is made by slow cooking pork until perfectly tender and juicy, then roasting the shredded pork for deliciously crisp edges.`, - ); - - // Verify first ingredient - cy.get('[name="ingredients[0].ingredient"]').should( - "have.value", - ` cup vegetable oil`, - ); - - // Verify last ingredient - cy.get('[name="ingredients[9].ingredient"]').should( - "have.value", - ` ( ounce) cans chicken broth`, - ); - - // Verify empty string ingredient from import was ignored - cy.get('[name="ingredients[10].ingredient"]').should("not.exist"); - - // Verify first instruction - cy.get('[name="instructions[0].type"]').should("have.value", "step"); - cy.get('[name="instructions[0].name"]').should("have.value", ""); - cy.get('[name="instructions[0].text"]').should( - "have.value", - "Gather all ingredients.", - ); - - // Image preview should be external link to image we will import - cy.findByRole("img").should( - "have.attr", - "src", - new URL("/uploads/pork-carnitas.webp", baseURL).href, - ); - }); - - cy.findByText("Submit").click(); - - // Ensure we're on the view page and not the new-project page - cy.findByLabelText("Multiply"); - - // Image should be newly created from the import's source - const processedImagePath = - "/image/project/pork-carnitas/uploads/pork-carnitas.webp/pork-carnitas-w3840q75.webp"; - - cy.findByRole("img").should("have.attr", "src", processedImagePath); - - cy.request({ - url: processedImagePath, - }) - .its("status") - .should("equal", 200); - - // Ensure resulting edit page works - - cy.findByText("Edit").click(); - - cy.findByText("Editing Project: Pork Carnitas"); - - cy.findByRole("img").should("have.attr", "src", processedImagePath); - }); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/pages.cy.ts b/websites/portfolio/editor/cypress/e2e/pages.cy.ts deleted file mode 100644 index 19dffa6..0000000 --- a/websites/portfolio/editor/cypress/e2e/pages.cy.ts +++ /dev/null @@ -1,93 +0,0 @@ -describe("Page Editor", function () { - describe("with a clean slate", function () { - it("should need authorization", function () { - cy.resetData(); - cy.visit("/pages"); - cy.findByText("Sign in with Credentials"); - }); - - it("should need authorization when directly going to an edit page", function () { - cy.resetData("about-page"); - cy.visit("/pages/edit/about"); - cy.findByText("Sign in with Credentials"); - }); - - describe("when authenticated", function () { - beforeEach(function () { - cy.resetData(); - cy.visit("/pages"); - cy.fillSignInForm(); - }); - - it("should be able to add, edit, and remove a page", function () { - // Confirm initial empty state - cy.findByText("There are no pages yet."); - - cy.request({ - url: "/my-new-page", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - - // Add new page - cy.findByText("New Page").click(); - - cy.findByText("Back to Pages"); - - cy.findByLabelText("Name").type("My New Page"); - cy.findByLabelText("Content").type( - "## Page Subtitle\n\nThis is a new page, *formatted* in **markdown**!", - ); - - cy.findByText("Submit").click(); - - // Confirm redirected to new page with given content - - cy.findByText(/^This is a new page/); - cy.findByText("formatted"); - cy.findByText("markdown"); - - cy.request({ - url: "/my-new-page", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 200); - - // Edit the page - cy.findByText("Edit").click(); - - cy.findByLabelText("Name").clear(); - cy.findByLabelText("Name").type("My New Edited Page"); - cy.findByLabelText("Content").clear(); - cy.findByLabelText("Content").type( - "## Page Subtitle\n\nThis is an edited page, *formatted* in **markdown**!\n\n- It has a list!\n\n- with two items!", - ); - - cy.findByText("Submit").click(); - - // Verify page is edited - - cy.findByText(/^This is an edited page/); - cy.findByText("formatted"); - cy.findByText("markdown"); - cy.findByText("It has a list!"); - cy.findByText("with two items!"); - - // Delete page - cy.findByText("Delete").click(); - - // Confirm page is deleted - cy.findByText("There are no pages yet."); - - cy.request({ - url: "/my-new-page", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - }); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/project.cy.ts b/websites/portfolio/editor/cypress/e2e/project.cy.ts deleted file mode 100644 index 402866c..0000000 --- a/websites/portfolio/editor/cypress/e2e/project.cy.ts +++ /dev/null @@ -1,94 +0,0 @@ -describe("Single Project View", function () { - describe("with seven items", function () { - beforeEach(function () { - cy.resetData("two-pages"); - cy.visit("/project/project-6"); - }); - - it("should display a project", function () { - cy.findByText("Project 6"); - }); - - it("should not need authorization", function () { - cy.findByText("Sign In"); - }); - - it("should be able to multiply ingredient amounts", function () { - cy.findByText("1 1/2 tsp salt"); - cy.findByText("Sprinkle 1/2 tsp salt in water"); - cy.findByLabelText("Multiply").type("2"); - cy.findByText("3 tsp salt"); - cy.findByText("Sprinkle 1 tsp salt in water"); - }); - - it("should be able to edit a project", function () { - cy.findByText("Edit").click(); - - cy.fillSignInForm(); - - cy.findByText("Editing Project: Project 6", { timeout: 10000 }); - - cy.findByText("Advanced").click(); - - const editedProject = "Edited Project"; - - cy.findAllByLabelText("Name").first().clear(); - cy.findAllByLabelText("Name").first().type(editedProject); - - const projectDate = "2023-12-08T01:16:12.622"; - cy.findByLabelText("Date (UTC)").should("have.value", projectDate); - - cy.findByText("Submit").click(); - - cy.findByText(editedProject); - - cy.visit("/"); - cy.findByText(editedProject); - cy.checkNamesInOrder([ - "Project 7", - editedProject, - "Project 5", - "Project 4", - "Project 3", - "Project 2", - ]); - - // Project date should not have changed - cy.findByText(new Date(projectDate + "Z").toLocaleString()); - }); - - it("should be able to delete the project", function () { - cy.findByText("Delete").click(); - - // First click of the delete button should trigger a sign-in - cy.fillSignInForm(); - - cy.findByText("Delete").click(); - - cy.findByText("Project 4"); - cy.checkNamesInOrder([ - "Project 7", - "Project 5", - "Project 4", - "Project 3", - "Project 2", - "Project 1", - ]); - cy.request({ - url: "/project/project-6", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - }); - }); - - it("should have status 404 when project doesn't exist", function () { - cy.request({ - url: "/project/non-existent-project", - failOnStatusCode: false, - }) - .its("status") - .should("equal", 404); - }); -}); diff --git a/websites/portfolio/editor/cypress/e2e/search.cy.ts b/websites/portfolio/editor/cypress/e2e/search.cy.ts deleted file mode 100644 index 2700d15..0000000 --- a/websites/portfolio/editor/cypress/e2e/search.cy.ts +++ /dev/null @@ -1,62 +0,0 @@ -describe("Search Page", function () { - describe("with seven items", function () { - beforeEach(function () { - cy.resetData("two-pages"); - cy.visit("/search"); - }); - - it("should not need authorization", function () { - cy.findByText("Sign In"); - }); - - it("should be able to find a single project by name", function () { - cy.findByLabelText("Query").type("Project 6"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("listitem") - .findByRole("heading") - .should("have.text", "Project 6"); - - cy.findByLabelText("Query").clear(); - cy.findByLabelText("Query").type("6 Project"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("listitem") - .findByRole("heading") - .should("have.text", "Project 6"); - - cy.findByLabelText("Query").clear(); - cy.findByLabelText("Query").type("project 6"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("listitem") - .findByRole("heading") - .should("have.text", "Project 6"); - - cy.findByLabelText("Query").clear(); - cy.findByLabelText("Query").type("6"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("listitem") - .findByRole("heading") - .should("have.text", "Project 6"); - - cy.findByLabelText("Query").clear(); - cy.findByLabelText("Query").type("5"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("listitem") - .findByRole("heading") - .should("have.text", "Project 5"); - }); - - it("should be able to find a project by ingredient", function () { - cy.findByLabelText("Query").type("sal"); - cy.findByRole("button", { name: "Submit" }).click(); - - cy.findByRole("heading", { name: "Project 6" }); - - cy.findByText(/^1 1\/2 tsp.*t$/).findByText("sal"); - }); - }); -}); diff --git a/websites/portfolio/editor/cypress/fixtures/example.json b/websites/portfolio/editor/cypress/fixtures/example.json deleted file mode 100644 index 02e4254..0000000 --- a/websites/portfolio/editor/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image-alternate.png b/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image-alternate.png deleted file mode 100644 index ecf7af2..0000000 Binary files a/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image-alternate.png and /dev/null differ diff --git a/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image.png b/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image.png deleted file mode 100644 index 90d8015..0000000 Binary files a/websites/portfolio/editor/cypress/fixtures/images/project-6-test-image.png and /dev/null differ diff --git a/websites/portfolio/editor/cypress/fixtures/users/admin@nextmail.com b/websites/portfolio/editor/cypress/fixtures/users/admin@nextmail.com deleted file mode 100644 index be6e347..0000000 --- a/websites/portfolio/editor/cypress/fixtures/users/admin@nextmail.com +++ /dev/null @@ -1 +0,0 @@ -{"email":"admin@nextmail.com","password":"$2b$10$82q8wy6VC18Xf8RlauuFluLOxHZJrnmTCwJuBUJX.j9AuxZpTYCma"} \ No newline at end of file diff --git a/websites/portfolio/editor/cypress/support/commands.ts b/websites/portfolio/editor/cypress/support/commands.ts deleted file mode 100644 index 897ea99..0000000 --- a/websites/portfolio/editor/cypress/support/commands.ts +++ /dev/null @@ -1,82 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('signIn', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// signIn(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } -import "@testing-library/cypress/add-commands"; - -interface SignInOptions { - email: string; - password: string; -} - -/* eslint-disable @typescript-eslint/no-namespace */ - -declare global { - namespace Cypress { - interface Chainable { - resetData(fixture?: string): Chainable; - fillSignInForm(user?: SignInOptions): Chainable; - signIn(user?: SignInOptions): Chainable; - checkNamesInOrder(names: string[]): Chainable; - } - } -} - -Cypress.Commands.add("resetData", (fixture) => { - cy.task("resetData", fixture); - fetch("http://localhost:3000/settings/invalidate-cache"); -}); - -Cypress.Commands.add("checkNamesInOrder", (names: string[]) => { - cy.findAllByRole("listitem").should("have.length", names.length); - cy.findAllByRole("listitem").each((el, i) => - cy.wrap(el).findByText(names[i]), - ); -}); - -Cypress.Commands.add( - "fillSignInForm", - ({ email = "admin@nextmail.com", password = "password" } = {}) => { - cy.findByLabelText("Email").type(email, { force: true }); - cy.findByLabelText("Password").type(password, { force: true }); - cy.findByText("Sign in with Credentials").click(); - }, -); - -Cypress.Commands.add("signIn", (options) => { - cy.findByText("Sign In").click(); - cy.fillSignInForm(options); -}); diff --git a/websites/portfolio/editor/cypress/support/e2e.ts b/websites/portfolio/editor/cypress/support/e2e.ts deleted file mode 100644 index 6a173d6..0000000 --- a/websites/portfolio/editor/cypress/support/e2e.ts +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/websites/portfolio/editor/cypress/tsconfig.json b/websites/portfolio/editor/cypress/tsconfig.json deleted file mode 100644 index 74b7cb3..0000000 --- a/websites/portfolio/editor/cypress/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "types": ["cypress", "@testing-library/cypress", "node"] - }, - "include": ["**/*.ts"] -} diff --git a/websites/recipe-website/common/components/RecipeImage/index.tsx b/websites/recipe-website/common/components/RecipeImage/index.tsx index 2539666..c67ff0b 100644 --- a/websites/recipe-website/common/components/RecipeImage/index.tsx +++ b/websites/recipe-website/common/components/RecipeImage/index.tsx @@ -1,7 +1,7 @@ import { join } from "path"; import { getContentDirectory } from "content-engine/fs/getContentDirectory"; import { - TransformedRecipeImageProps, + TransformedStaticImageProps, getStaticImageProps, } from "next-static-image/src"; import Image from "next/image"; @@ -18,7 +18,7 @@ export async function getTransformedRecipeImageProps({ loading, sizes, className, -}: TransformedRecipeImageProps) { +}: TransformedStaticImageProps) { if (!image) return undefined; const srcPath = getRecipeUploadPath(getContentDirectory(), slug, image); try { @@ -44,7 +44,7 @@ export async function getTransformedRecipeImageProps({ } } -export async function RecipeImage(inputProps: TransformedRecipeImageProps) { +export async function RecipeImage(inputProps: TransformedStaticImageProps) { const image = await getTransformedRecipeImageProps(inputProps); if (image) { return ( diff --git a/websites/recipe-website/editor/cypress.config.ts b/websites/recipe-website/editor/cypress.config.ts index f6cf27a..e5397ac 100644 --- a/websites/recipe-website/editor/cypress.config.ts +++ b/websites/recipe-website/editor/cypress.config.ts @@ -41,6 +41,18 @@ export default defineConfig({ return null; }, resetData, + async loadGitFixture(fixture: string) { + const outputDir = resolve("test-content"); + await remove(outputDir); + const fixtureBundlePath = resolve( + "cypress", + "fixtures", + "git-test-content", + fixture, + ); + await simpleGit().clone(fixtureBundlePath, outputDir); + return null; + }, }); }, retries: { diff --git a/websites/recipe-website/editor/cypress/e2e/git.cy.ts b/websites/recipe-website/editor/cypress/e2e/git.cy.ts index fd828e9..cc2c6a9 100644 --- a/websites/recipe-website/editor/cypress/e2e/git.cy.ts +++ b/websites/recipe-website/editor/cypress/e2e/git.cy.ts @@ -1,11 +1,169 @@ describe("Git content", function () { describe("when empty", function () { + describe("Git remote management", function () { + it("should allow creating a new remote", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("New Remote").click(); + cy.findByLabelText("Remote Name").type("origin"); + cy.findByLabelText("Remote URL").type( + "https://github.com/user/repo.git", + ); + cy.findByText("Add").click(); + + cy.findByText("origin").should("exist"); + cy.findByText("https://github.com/user/repo.git").should("exist"); + }); + + it("should display an error message when creating a remote with an empty name", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("New Remote").click(); + cy.findByLabelText("Remote URL").type( + "https://github.com/user/repo.git", + ); + cy.findByText("Add").click(); + + cy.findByText("Remote Name is required").should("exist"); + }); + + it("should display an error message when creating a remote with an empty URL", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("New Remote").click(); + cy.findByLabelText("Remote Name").type("origin"); + cy.findByText("Add").click(); + + cy.findByText("Remote URL is required").should("exist"); + }); + + it("should display an error message when creating a remote with a duplicate name", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("New Remote").click(); + cy.findByLabelText("Remote Name").type("origin"); + cy.findByLabelText("Remote URL").type( + "https://github.com/user/repo.git", + ); + cy.findByText("Add").click(); + + cy.findByText("origin").should("exist"); + + cy.findByLabelText("Remote Name").type("origin"); + cy.findByLabelText("Remote URL").type( + "https://github.com/user/repo2.git", + ); + cy.findByText("Add").click(); + + cy.findByText("error: remote origin already exists.").should("exist"); + }); + }); + + it("should navigate to the Git UI from home and create a branch", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/"); + + cy.findByText("Settings").click(); + cy.fillSignInForm(); + cy.findByText("Git").click(); + + cy.findByLabelText("Branch Name").type("other-branch"); + cy.findByText("Create").click(); + cy.findByText("Branches").should("exist"); + cy.findByText("other-branch").should("exist"); + }); + + it("should initialize a Git repository", function () { + cy.resetData(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("Content directory is not tracked with Git.").should( + "exist", + ); + + cy.findByText("Initialize").click(); + cy.findByText("Content directory is not tracked with Git.").should( + "not.exist", + ); + cy.findByText("Branches").should("exist"); + }); + + it("should display an error message when creating a branch with an empty name", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("Create").click(); + + cy.findByText("Branch Name is required").should("exist"); + }); + + it("should display an error message when using checkout with no selected branch", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByRole("radio").should("not.be.checked"); + + cy.findByText("Checkout").should("be.disabled"); + cy.findByText("Checkout").invoke("attr", "disabled", false); + cy.findByText("Checkout").click({ force: true }); + + cy.findByText("Invalid branch").should("exist"); + }); + + it("should display an error message when using delete with no selected branch", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByRole("radio").should("not.be.checked"); + + cy.findByText("Delete").should("be.disabled"); + cy.findByText("Delete").invoke("attr", "disabled", false); + cy.findByText("Delete").click({ force: true }); + + cy.findByText("Invalid branch").should("exist"); + }); + + it("should display an error message when using force delete with no selected branch", function () { + cy.resetData(); + cy.initializeContentGit(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByRole("radio").should("not.be.checked"); + + cy.findByText("Force Delete").should("be.disabled"); + cy.findByText("Force Delete").invoke("attr", "disabled", false); + cy.findByText("Force Delete").click({ force: true }); + + cy.findByText("Invalid branch").should("exist"); + }); + it("should indicate when the content directory is not tracked by git", function () { cy.resetData(); cy.visit("/git"); cy.fillSignInForm(); - cy.findByText("Content directory is not tracked with Git"); + cy.findByText("Content directory is not tracked with Git."); cy.findAllByText("Branches").should("not.exist"); }); @@ -106,5 +264,101 @@ describe("Git content", function () { cy.findAllByText("other-branch").should("not.exist"); }); + + it("should display an empty git log", function () { + cy.resetData(); + cy.visit("/git"); + cy.fillSignInForm(); + + cy.findByText("Initialize").click(); + + cy.findByText("No commits yet").should("exist"); + }); + }); + + describe("with some git history", function () { + const firstRecipeSlug = "recipe-a"; + const secondRecipeSlug = "recipe-b"; + + beforeEach(function () { + cy.loadGitFixture("test-git.bundle"); + cy.visit("/git"); + cy.fillSignInForm(); + }); + + it("should display the git log below the branches menu", function () { + cy.visit("/git"); + cy.findByText("Branches").should("exist"); + cy.findByText("Initial Commit").should("exist"); + cy.findByText(`Add new recipe: ${firstRecipeSlug}`).should("exist"); + cy.findByText(`Add new recipe: ${secondRecipeSlug}`).should("exist"); + cy.findByText(`Update recipe: ${secondRecipeSlug}`).should("exist"); + cy.findByText(`Delete recipe: ${firstRecipeSlug}`).should("exist"); + }); + + it("should display the correct commit order in the git log", function () { + cy.visit("/git"); + cy.getContentGitLog().should("have.ordered.members", [ + `Delete recipe: ${firstRecipeSlug}`, + `Update recipe: ${secondRecipeSlug}`, + `Add new recipe: ${secondRecipeSlug}`, + `Add new recipe: ${firstRecipeSlug}`, + "Initial Commit", + ]); + }); + + it("should display commit details when clicking on a commit", function () { + cy.visit("/git"); + cy.findByText(`Update recipe: ${secondRecipeSlug}`).click(); + + cy.findByText("Commit Details").should("exist"); + cy.findByText(`Update recipe: ${secondRecipeSlug}`).should("exist"); + cy.findByText("Author").should("exist"); + cy.findByText("Date").should("exist"); + cy.findByText("Diff").should("exist"); + + cy.findByText("Close").click(); + cy.findByText("Commit Details").should("not.exist"); + }); + + it("should display the correct commit details", function () { + cy.visit("/git"); + cy.findByText(`Update recipe: ${secondRecipeSlug}`).click(); + + cy.findByText("Commit Details").should("exist"); + cy.findByText(`Update recipe: ${secondRecipeSlug}`).should("exist"); + cy.findByText("Author").should("exist"); + cy.findByText("Date").should("exist"); + cy.findByText("Diff").should("exist"); + + cy.findByText(/-.*Recipe B/).should("exist"); + cy.findByText(/\+.*edited/).should("exist"); + }); + + it("should display the correct commit details for a delete commit", function () { + cy.visit("/git"); + cy.findByText(`Delete recipe: ${firstRecipeSlug}`).click(); + + cy.findByText("Commit Details").should("exist"); + cy.findByText(`Delete recipe: ${firstRecipeSlug}`).should("exist"); + cy.findByText("Author").should("exist"); + cy.findByText("Date").should("exist"); + cy.findByText("Diff").should("exist"); + + cy.findByText(/-.*Recipe A/).should("exist"); + }); + + it("should display the correct commit details for an add commit", function () { + cy.visit("/git"); + cy.findByText(`Add new recipe: ${firstRecipeSlug}`).click(); + + cy.findByText("Commit Details").should("exist"); + cy.findByText(`Add new recipe: ${firstRecipeSlug}`).should("exist"); + cy.findByText("Author").should("exist"); + cy.findByText("Date").should("exist"); + cy.findByText("Diff").should("exist"); + + cy.findByText(/\+.*Recipe A/).should("exist"); + }); }); }); diff --git a/websites/recipe-website/editor/cypress/e2e/new-recipe.cy.ts b/websites/recipe-website/editor/cypress/e2e/new-recipe.cy.ts index 443ec71..ec4ac8c 100644 --- a/websites/recipe-website/editor/cypress/e2e/new-recipe.cy.ts +++ b/websites/recipe-website/editor/cypress/e2e/new-recipe.cy.ts @@ -14,6 +14,34 @@ describe("New Recipe View", function () { cy.fillSignInForm(); }); + it("should resize the image in an imported recipe", function () { + const baseURL = Cypress.config().baseUrl; + const testURL = "/uploads/blackstone-nachos.html"; + const fullTestURL = new URL(testURL, baseURL); + cy.findByLabelText("Import from URL").type(fullTestURL.href); + cy.findByRole("button", { name: "Import" }).click(); + cy.url().should( + "equal", + new URL( + "/new-recipe?import=http%3A%2F%2Flocalhost%3A3000%2Fuploads%2Fblackstone-nachos.html", + baseURL, + ).href, + ); + + cy.findByText("Submit").click(); + + // Ensure we're on the view page and not the new-recipe page + cy.findByLabelText("Multiply", { timeout: 10000 }); + + // Check if the image is resized correctly + cy.findByRole("img").should(($img) => { + const img = $img[0] as HTMLImageElement; + // Adjust dimensions to the expected size of your image + expect(img.naturalWidth).to.eq(566); + expect(img.naturalHeight).to.eq(566); + }); + }); + it("should be able to add a new ingredient", function () { cy.findByRole("heading", { name: "New Recipe" }); @@ -179,7 +207,7 @@ describe("New Recipe View", function () { cy.findByRole("heading", { name: newRecipeTitle }).should("not.exist"); }); - it("should be able to add a video to a new recipe", function () { + it("should be able to add a new recipe with a video", function () { cy.findByRole("heading", { name: "New Recipe" }); const newRecipeTitle = "My New Recipe with Video"; @@ -215,7 +243,7 @@ describe("New Recipe View", function () { // Test VideoTime component's timestamp link cy.findByText("10s").click(); - cy.get("video", { timeout: 10000 }).should(($video) => { + cy.get("video", { timeout: 8000 }).should(($video) => { expect($video[0].currentTime).to.be.closeTo(10, 1); // Adjust the time as per your test video }); }); @@ -455,19 +483,18 @@ Have no number on three cy.findByRole("img").should( "have.attr", "src", - new URL("/uploads/2021-11-28_0107-scaled-720x720.png", baseURL) - .href, + new URL("/uploads/recipe-imported-image-566x566.png", baseURL).href, ); }); cy.findByText("Submit").click(); // Ensure we're on the view page and not the new-recipe page - cy.findByLabelText("Multiply"); + cy.findByLabelText("Multiply", { timeout: 10000 }); // Image should be newly created from the import's source const processedImagePath = - "/image/uploads/recipe/blackstone-griddle-grilled-nachos/uploads/2021-11-28_0107-scaled-720x720.png/2021-11-28_0107-scaled-720x720-w3840q75.webp"; + "/image/uploads/recipe/blackstone-griddle-grilled-nachos/uploads/recipe-imported-image-566x566.png/recipe-imported-image-566x566-w3840q75.webp"; cy.findByRole("img").should("have.attr", "src", processedImagePath); diff --git a/websites/recipe-website/editor/cypress/fixtures/git-test-content/test-git.bundle b/websites/recipe-website/editor/cypress/fixtures/git-test-content/test-git.bundle new file mode 100644 index 0000000..7bc9170 Binary files /dev/null and b/websites/recipe-website/editor/cypress/fixtures/git-test-content/test-git.bundle differ diff --git a/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/blackstone-nachos.html b/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/blackstone-nachos.html index 1f8bfab..b0e1e51 100644 --- a/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/blackstone-nachos.html +++ b/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/blackstone-nachos.html @@ -268,7 +268,7 @@ "recipeYield": 10, "description": "Who doesn\u2019t love nachos? Jazz up your nacho routine with these super-tasty Blackstone Nachos Supreme. Made effortlessly on your Blackstone Griddle, there\u2019s nothing like this towering pile of crispy chips and delish toppings for your next snack attack.", "image": [ - "http:\/\/localhost:3000\/uploads\/2021-11-28_0107-scaled-720x720.png", + "http:\/\/localhost:3000\/uploads\/recipe-imported-image-566x566.png", "http:\/\/localhost:3000\/uploads\/2021-11-28_0107-scaled-720x540.png", "http:\/\/localhost:3000\/uploads\/2021-11-28_0107-scaled-540x720.png", "http:\/\/localhost:3000\/uploads\/2021-11-28_0107-scaled-720x405.png", diff --git a/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/2021-11-28_0107-scaled-720x720.png b/websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/recipe-imported-image-566x566.png similarity index 100% rename from websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/2021-11-28_0107-scaled-720x720.png rename to websites/recipe-website/editor/cypress/fixtures/test-content/importable-uploads/uploads/recipe-imported-image-566x566.png diff --git a/websites/recipe-website/editor/cypress/support/commands.ts b/websites/recipe-website/editor/cypress/support/commands.ts index 31def61..653408d 100644 --- a/websites/recipe-website/editor/cypress/support/commands.ts +++ b/websites/recipe-website/editor/cypress/support/commands.ts @@ -48,6 +48,7 @@ declare global { namespace Cypress { interface Chainable { resetData(fixture?: string): Chainable; + loadGitFixture(fixture?: string): Chainable; initializeContentGit(fixture?: string): Chainable; getContentGitLog(): Chainable; fillSignInForm(user?: SignInOptions): Chainable; @@ -59,7 +60,12 @@ declare global { Cypress.Commands.add("resetData", (fixture) => { cy.task("resetData", fixture); - fetch("http://localhost:3000/settings/invalidate-cache"); + cy.request("http://localhost:3000/settings/invalidate-cache"); +}); + +Cypress.Commands.add("loadGitFixture", (fixture) => { + cy.task("loadGitFixture", fixture); + cy.request("http://localhost:3000/settings/invalidate-cache"); }); Cypress.Commands.add("getContentGitLog", () => { diff --git a/websites/recipe-website/editor/src/app/(editor)/git/BranchSelector.tsx b/websites/recipe-website/editor/src/app/(editor)/git/BranchSelector.tsx index a2c1416..f95aea2 100644 --- a/websites/recipe-website/editor/src/app/(editor)/git/BranchSelector.tsx +++ b/websites/recipe-website/editor/src/app/(editor)/git/BranchSelector.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { SubmitButton } from "component-library/components/SubmitButton"; import { branchCommandAction } from "./actions"; import clsx from "clsx"; @@ -15,6 +15,7 @@ export function BranchSelector({ branchCommandAction, null, ); + const [branchSelected, setBranchSelected] = useState(false); return (
{branchCommandState && ( @@ -35,6 +36,7 @@ export function BranchSelector({ value={name} type="radio" disabled={current} + onChange={() => setBranchSelected(true)} />{" "} {name} @@ -48,6 +50,7 @@ export function BranchSelector({ className="border border-white rounded px-2 py-1" name="command" value="checkout" + disabled={!branchSelected} > Checkout @@ -56,6 +59,7 @@ export function BranchSelector({ className="border border-white rounded px-2 py-1" name="command" value="delete" + disabled={!branchSelected} > Delete @@ -64,6 +68,7 @@ export function BranchSelector({ className="border border-white rounded px-2 py-1 bg-orange-950" name="command" value="forceDelete" + disabled={!branchSelected} > Force Delete diff --git a/websites/recipe-website/editor/src/app/(editor)/git/CreateBranchForm.tsx b/websites/recipe-website/editor/src/app/(editor)/git/CreateBranchForm.tsx new file mode 100644 index 0000000..e04548f --- /dev/null +++ b/websites/recipe-website/editor/src/app/(editor)/git/CreateBranchForm.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { TextInput } from "component-library/components/Form/inputs/Text"; +import { useActionState } from "react"; +import { createBranch } from "./actions"; +import { SubmitButton } from "component-library/components/SubmitButton"; + +const CREATE_BRANCH_BUTTON_TEXT = "Create"; +const BRANCH_SELECTOR_LABEL = "Branch Name"; + +export function CreateBranchForm() { + const [createBranchState, createBranchWithState] = useActionState( + createBranch, + undefined, + ); + return ( + + {createBranchState && ( +
+ {createBranchState} +
+ )} + +
+ {CREATE_BRANCH_BUTTON_TEXT} +
+ + ); +} diff --git a/websites/recipe-website/editor/src/app/(editor)/git/CreateRemoteForm.tsx b/websites/recipe-website/editor/src/app/(editor)/git/CreateRemoteForm.tsx new file mode 100644 index 0000000..3fbfea9 --- /dev/null +++ b/websites/recipe-website/editor/src/app/(editor)/git/CreateRemoteForm.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { TextInput } from "component-library/components/Form/inputs/Text"; +import { useActionState } from "react"; +import { createRemote } from "./actions"; +import { SubmitButton } from "component-library/components/SubmitButton"; + +const CREATE_REMOTE_BUTTON_TEXT = "Add"; +const REMOTE_NAME_LABEL = "Remote Name"; +const REMOTE_URL_LABEL = "Remote URL"; + +export function CreateRemoteForm() { + const [createRemoteState, createRemoteWithState] = useActionState( + createRemote, + undefined, + ); + return ( +
+ {createRemoteState && ( +
+ {createRemoteState} +
+ )} + + +
+ {CREATE_REMOTE_BUTTON_TEXT} +
+ + ); +} diff --git a/websites/recipe-website/editor/src/app/(editor)/git/GitLog.tsx b/websites/recipe-website/editor/src/app/(editor)/git/GitLog.tsx new file mode 100644 index 0000000..e21a604 --- /dev/null +++ b/websites/recipe-website/editor/src/app/(editor)/git/GitLog.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React, { useState } from "react"; + +interface LogEntry { + hash: string; + date: string; + message: string; + author_name: string; +} + +interface GitLogProps { + log: (LogEntry & { diff: string })[]; +} + +const GitLogItem = ({ entry }: { entry: LogEntry & { diff: string } }) => { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(!open)} className="my-1 font-lg font-bold"> + {entry.message} +
+ {open && ( +
+
Commit Details
+
    +
  • + Author: {entry.author_name} +
  • +
  • + Date: {entry.date} +
  • +
  • + Diff:
    {entry.diff}
    +
  • +
+
+ +
+
+ )} +
+ ); +}; + +export const GitLog: React.FC = ({ log }) => { + return ( +
    + {log && log.length > 1 + ? log.map((entry) => ( +
  • + +
  • + )) + : "No commits yet"} +
+ ); +}; diff --git a/websites/recipe-website/editor/src/app/(editor)/git/RemoteSelector.tsx b/websites/recipe-website/editor/src/app/(editor)/git/RemoteSelector.tsx new file mode 100644 index 0000000..1c527dc --- /dev/null +++ b/websites/recipe-website/editor/src/app/(editor)/git/RemoteSelector.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useActionState } from "react"; +import { SubmitButton } from "component-library/components/SubmitButton"; +import { remoteCommandAction } from "./actions"; +import { RemoteWithRefs } from "simple-git"; + +export function RemoteSelector({ remotes }: { remotes: RemoteWithRefs[] }) { + const [remoteCommandState, remoteCommandActionWithState] = useActionState( + remoteCommandAction, + null, + ); + return ( +
+ {remoteCommandState && ( +
+ {remoteCommandState} +
+ )} +
    + {remotes.map(({ name, refs }) => { + return ( +
  • + +
  • + ); + })} +
+
+ + Pull + + + Push + +
+
+ ); +} diff --git a/websites/recipe-website/editor/src/app/(editor)/git/actions.ts b/websites/recipe-website/editor/src/app/(editor)/git/actions.ts index 3b6a51a..a9c16c8 100644 --- a/websites/recipe-website/editor/src/app/(editor)/git/actions.ts +++ b/websites/recipe-website/editor/src/app/(editor)/git/actions.ts @@ -6,18 +6,103 @@ import { getContentDirectory } from "content-engine/fs/getContentDirectory"; import { directoryIsGitRepo } from "content-engine/git/commit"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +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 createRemote( + _state: string | undefined, + formData: FormData, +) { + "use server"; + const contentDirectory = getContentDirectory(); + const result = remoteSchema.safeParse({ + remoteName: formData.get("remoteName"), + remoteUrl: formData.get("remoteUrl"), + }); + + if (!result.success) { + return ( + result.error.flatten().fieldErrors.remoteName?.[0] ?? + result.error.flatten().fieldErrors.remoteUrl?.[0] + ); + } + + if (await directoryIsGitRepo(contentDirectory)) { + try { + await simpleGit(contentDirectory).addRemote( + result.data.remoteName, + result.data.remoteUrl, + ); + } catch (e) { + if ( + e && + typeof e === "object" && + "message" in e && + typeof e.message === "string" + ) { + return e.message; + } else { + throw e; + } + } + } + revalidatePath("/git"); +} + +export async function createBranch( + _state: string | undefined, + formData: FormData, +) { + "use server"; + const contentDirectory = getContentDirectory(); + const branchName = formData.get("branchName") as string; + if (!branchName) { + return "Branch Name is required"; + } + if (await directoryIsGitRepo(contentDirectory)) { + try { + await simpleGit(contentDirectory).checkout(["-b", branchName]); + } catch (e) { + if ( + e && + typeof e === "object" && + "message" in e && + typeof e.message === "string" + ) { + return e.message; + } else { + throw e; + } + } + } + revalidatePath("/git"); +} + const commandHandlers: Record< string, (args: { contentDirectory: string; branch: string }) => Promise > = { async checkout({ contentDirectory, branch }) { + if (!branch) { + throw new Error("Invalid branch"); + } await simpleGit(contentDirectory).checkout(branch); await rebuildRecipeIndex(); }, async delete({ contentDirectory, branch }) { + if (!branch) { + throw new Error("Invalid branch"); + } await simpleGit(contentDirectory).deleteLocalBranch(branch); }, async forceDelete({ contentDirectory, branch }) { + if (!branch) { + throw new Error("Invalid branch"); + } await simpleGit(contentDirectory).deleteLocalBranch(branch, true); }, }; @@ -28,34 +113,42 @@ export async function branchCommandAction( ): Promise { const command = formData.get("command"); if (typeof command !== "string") { - throw new Error("No command provided!"); + return "No command provided!"; } const commandHandler = commandHandlers[command]; if (!commandHandler) { - throw new Error(`Invalid command: ${command}`); + return `Invalid command: ${command}`; } const branch = formData.get("branch"); if (typeof branch !== "string") { - throw new Error(`Invalid branch: ${branch}`); + return `Invalid branch`; } const contentDirectory = getContentDirectory(); - if (await directoryIsGitRepo(contentDirectory)) { - try { - await commandHandler({ contentDirectory, branch }); - } catch (e) { - if ( - e && - typeof e === "object" && - "message" in e && - typeof e.message === "string" - ) { - return e.message; - } else { - throw e; - } + if (!(await directoryIsGitRepo(contentDirectory))) { + return "Content directory is not a Git repository."; + } + try { + await commandHandler({ contentDirectory, branch }); + } catch (e) { + if ( + e && + typeof e === "object" && + "message" in e && + typeof e.message === "string" + ) { + return e.message; + } else { + throw e; } - await rebuildRecipeIndex(); - revalidatePath("/git"); } + await rebuildRecipeIndex(); + revalidatePath("/git"); + return null; +} + +export async function remoteCommandAction( + _previousState: string | null, + _formData: FormData, +): Promise { return null; } diff --git a/websites/recipe-website/editor/src/app/(editor)/git/constants.ts b/websites/recipe-website/editor/src/app/(editor)/git/constants.ts new file mode 100644 index 0000000..01263ca --- /dev/null +++ b/websites/recipe-website/editor/src/app/(editor)/git/constants.ts @@ -0,0 +1,3 @@ +export const BRANCH_NAME_KEY = "branchName"; +export const GIT_PATH = "/git"; +export const INITIAL_COMMIT_MESSAGE = "Initial commit"; diff --git a/websites/recipe-website/editor/src/app/(editor)/git/page.tsx b/websites/recipe-website/editor/src/app/(editor)/git/page.tsx index d6c0547..7d9c407 100644 --- a/websites/recipe-website/editor/src/app/(editor)/git/page.tsx +++ b/websites/recipe-website/editor/src/app/(editor)/git/page.tsx @@ -2,17 +2,26 @@ import { auth, signIn } from "@/auth"; import simpleGit from "simple-git"; import { getContentDirectory } from "content-engine/fs/getContentDirectory"; import { directoryIsGitRepo } from "content-engine/git/commit"; -import { TextInput } from "component-library/components/Form/inputs/Text"; import { SubmitButton } from "component-library/components/SubmitButton"; import { revalidatePath } from "next/cache"; import { BranchSelector } from "./BranchSelector"; +import { CreateBranchForm } from "./CreateBranchForm"; +import { GitLog } from "./GitLog"; +import { RemoteSelector } from "./RemoteSelector"; +import { CreateRemoteForm } from "./CreateRemoteForm"; -async function createBranch(formData: FormData) { +const INITIALIZE_BUTTON_TEXT = "Initialize"; +const INITIAL_COMMIT_MESSAGE = "Initial commit"; + +async function initializeContentGit() { "use server"; + const contentDirectory = getContentDirectory(); - const branchName = formData.get("branchName") as string; - if (await directoryIsGitRepo(contentDirectory)) { - await simpleGit(contentDirectory).checkout(["-b", branchName]); + if (!(await directoryIsGitRepo(contentDirectory))) { + const git = simpleGit(contentDirectory); + await git.init(); + await git.add("."); + await git.commit(INITIAL_COMMIT_MESSAGE); } revalidatePath("/git"); } @@ -21,7 +30,10 @@ async function GitPageWithoutGit() { return ( <>

- Content directory is not tracked with Git + Content directory is not tracked with Git. +
+ {INITIALIZE_BUTTON_TEXT} +

); @@ -33,21 +45,37 @@ async function GitPageWithGit({ contentDirectory: string; }) { const contentGit = simpleGit(contentDirectory); - const branchSummary = await contentGit.branch(); + const branchSummary = await contentGit.branchLocal(); const branches = Object.values(branchSummary.branches); + const remotes = await contentGit.getRemotes(true); + const log = await contentGit.log(); + const entriesWithDiffs = await Promise.all( + log.all.map(async (entry) => ({ + ...entry, + diff: await contentGit.show(entry.hash), + })), + ); + return ( <>

Branches

New Branch

-
- -
- Create -
- + +
+
+

Commit History

+
+

Remotes

+ +
+ + New Remote + + +
); }