diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..723654dc --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +API_KEY= anything +AUTH_SECRET= can be anything +DATABASE_URL= need to set up a postgres instance and use a uri that points to it +EMAIL_ADMIN_DESTINATIONS= comma separated email addresses +EMAIL_FROM= email address +EMAIL_SERVER= email uri (ethereal mail) +GCP_DATA_PROJECT_ID= need to set up a GCP project (free) +GOOGLE_LOGO_BUCKET_BUCKET_NAME= create a GCP bucket +GOOGLE_LOGO_BUCKET_CLIENT_EMAIL= create a service account +GOOGLE_LOGO_BUCKET_PRIVATE_KEY= private key for the service account +GOOGLE_LOGO_BUCKET_PROJECT_ID= GCP project id +GOOGLE_SERVICE_ACCOUNT_CLIENT_EMAIL= Not needed unless running migration +GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY= Not needed unless running migration +GOOGLE_SHEET_ID= Not needed unless running migration +GOOGLE_SHEET_ID_GRAVITY_FORMS= Not needed unless running migration +NEXT_PUBLIC_CHANNEL=local +NEXT_PUBLIC_GA_MEASUREMENT_ID= optional +NEXT_PUBLIC_GOOGLE_API_KEY= create an api key in GCP +NEXT_PUBLIC_URL= http://localhost:3000 for local development +SUPER_ADMIN_API_KEY= anything +TEST_DATABASE_URL= create a second database in postgres \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 9785090e..3461d680 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,25 @@ "outFiles": ["${workspaceFolder}/**/*.js"], "console": "integratedTerminal" }, + { + "name": "Next.js: Full Stack Debug", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/apps/nextjs", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["dev"], + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + }, + "skipFiles": ["/**"], + "outFiles": ["${workspaceFolder}/**/*.js"], + "console": "integratedTerminal", + "env": { + "NODE_OPTIONS": "--inspect" + } + }, { "type": "node", "request": "launch", diff --git a/README.md b/README.md index 3a00999d..5b89f896 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,12 @@ Environment variables are application-specific in this monorepo. The `.env` file - For dev migrations use the `dev_generic` user - For staging migrations use the `dev_generic` user - For production migrations use the `f3slackbot` user + +# WIP + +- ensure we can't submit with no time and no events for new events +- add tests for approvals in the admin portal +- test image issues +- better text of where we're moving aos and regions +- decide which things you can change region and and which you can't +- TODO: Think through AO and location appearance diff --git a/apps/map/e2e/auth/admin-portal.spec.ts b/apps/map/e2e/auth/admin-portal.spec.ts new file mode 100644 index 00000000..1b97550e --- /dev/null +++ b/apps/map/e2e/auth/admin-portal.spec.ts @@ -0,0 +1,357 @@ +import { expect, test } from "@playwright/test"; + +import { + addNewItemAndVerify, + cleanupTestData, + deleteItemAndVerify, + editItemAndVerify, + fillTimeInput, + goToAdminPortal, + goToSection, +} from "../helpers"; + +const prefix = "SUPER TEST"; + +// To run this file: +// 1. pnpm -F nextjs test:e2e:ui --grep="admin" +// 2. Commend out beforeAll cleanupTestData (~line 15) if running directly +test.describe("Admin Portal", () => { + test.beforeAll(async ({ browser }) => { + // Create a new page for cleanup only + const context = await browser.newContext(); + const page = await context.newPage(); + + // Clean up any leftover test data before starting tests + await cleanupTestData({ page, prefix }); + + // Close the context when done + await context.close(); + }); + + test.beforeEach(async ({ page }) => { + // await setupAdminTestEnvironment(page); + await goToAdminPortal(page); + }); + + test("go to Sectors and add a new Sector", async ({ page }) => { + await expect(page).toHaveURL(/\/admin/); + await goToSection(page, "Sectors"); + await addNewItemAndVerify({ + page, + section: "Sector", + extraSteps: async () => { + await page.getByRole("combobox").filter({ hasText: /^$/ }).click(); + await page.getByRole("option", { name: "F3 Nation" }).click(); + }, + prefix, + }); + }); + + test("edit the new Sector", async ({ page }) => { + await goToSection(page, "Sectors"); + await editItemAndVerify({ + page, + name: "SECTOR", + order: 2, + prefix, + }); + }); + + test("go to Areas and add a new Area", async ({ page }) => { + await expect(page).toHaveURL(/\/admin/); + await goToSection(page, "Areas"); + await addNewItemAndVerify({ + page, + section: "Area", + extraSteps: async () => { + await page.getByRole("combobox").filter({ hasText: /^$/ }).click(); + await page.getByRole("option", { name: "SUPER TEST SECTOR" }).click(); + }, + prefix, + }); + }); + + test("edit the new Area", async ({ page }) => { + await goToSection(page, "Areas"); + await editItemAndVerify({ + page, + name: "AREA", + order: 2, + prefix, + }); + }); + + test("go to Regions and add a new Region", async ({ page }) => { + await expect(page).toHaveURL(/\/admin/); + await goToSection(page, "Regions"); + await addNewItemAndVerify({ + page, + section: "Region", + extraSteps: async () => { + await page.getByRole("combobox").filter({ hasText: /^$/ }).click(); + await page.getByRole("option", { name: `${prefix} AREA 2` }).click(); + }, + prefix, + }); + }); + + test("edit the new Region", async ({ page }) => { + await goToSection(page, "Regions"); + await editItemAndVerify({ + page, + name: "REGION", + order: 2, + prefix, + }); + }); + + test("go to AOs and add a new AO", async ({ page }) => { + await expect(page).toHaveURL(/\/admin/); + await goToSection(page, "AOs"); + await addNewItemAndVerify({ + page, + section: "AO", + prefix, + extraSteps: async () => { + await page.getByRole("combobox").filter({ hasText: /^$/ }).click(); + await page.getByPlaceholder("Select a region").fill("super"); + await page + .getByRole("option", { name: `${prefix} REGION 2` }) + .first() + .click(); + }, + }); + }); + + test("edit the new AO", async ({ page }) => { + await goToSection(page, "AOs"); + await editItemAndVerify({ + page, + name: "AO", + order: 2, + prefix, + }); + }); + + test("go to Event Types and add a new Event Type", async ({ page }) => { + await goToSection(page, "Event Types"); + await addNewItemAndVerify({ + page, + section: "Event Type", + prefix, + extraSteps: async () => { + await page + .locator('textarea[name="description"]') + .fill("Test event type description"); + await page.getByRole("combobox", { name: "Event Category" }).click(); + await page.getByRole("option", { name: "1st F" }).click(); + await page + .getByRole("combobox") + .filter({ hasText: "Select a region" }) + .click(); + await page.getByPlaceholder("Select a region").fill("SUPER"); + await page + .getByRole("option", { name: "SUPER TEST REGION 2" }) + .first() + .click(); + }, + }); + }); + + test("edit the new Event Type", async ({ page }) => { + await goToSection(page, "Event Types"); + await editItemAndVerify({ + page, + name: "EVENT TYPE", + order: 2, + prefix, + }); + }); + + test("go to Locations and add a new Location", async ({ page }) => { + await goToSection(page, "Locations"); + await addNewItemAndVerify({ + page, + section: "Location", + prefix, + extraSteps: async () => { + await page + .getByRole("textbox", { name: "Name" }) + .fill("SUPER TEST LOCATION"); + await page.waitForTimeout(250); + await page + .getByRole("combobox") + .filter({ hasText: "Select a region" }) + .click(); + await page.getByPlaceholder("Select a region").fill(prefix); + await page + .getByRole("option", { name: `${prefix} REGION 2` }) + .first() + .click(); + }, + }); + }); + + test("edit the new Location", async ({ page }) => { + await goToSection(page, "Locations"); + await editItemAndVerify({ + page, + name: "LOCATION", + order: 2, + prefix, + }); + }); + + test("go to Events and add a new Event", async ({ page }) => { + await goToSection(page, "Events"); + await addNewItemAndVerify({ + page, + section: "Event", + prefix, + extraSteps: async () => { + //Select the region + await page + .getByRole("combobox") + .filter({ hasText: "Select a region" }) + .click(); + await page.getByPlaceholder("Select a region").fill("SUPER"); + await page + .getByRole("option", { name: `${prefix} REGION 2` }) + .first() + .click(); + + // Select the AO + await page + .getByRole("combobox") + .filter({ hasText: "Select an AO" }) + .click(); + await page + .getByRole("option", { name: `${prefix} AO 2` }) + .first() + .click(); + + // See if location already is selected + try { + await expect( + page + .getByRole("combobox") + .filter({ hasText: `${prefix} LOCATION 2` }), + ).toBeVisible(); + } catch (error) { + // Select a location + await page + .getByRole("combobox") + .filter({ hasText: "Select a location" }) + .click(); + await page.getByPlaceholder("Select a location").fill(prefix); + await page + .getByRole("option", { name: `${prefix} LOCATION 2` }) + .first() + .click(); + } + + // Day of the week + await page.getByRole("combobox", { name: "Day of Week" }).click(); + await page.getByRole("option", { name: "Wednesday" }).click(); + + // Select Event Type + await page + .getByRole("combobox") + .filter({ hasText: "Select event types" }) + .click(); + await page.getByPlaceholder("Select event types").fill(prefix); + await page + .getByRole("option", { name: `${prefix} EVENT TYPE 2` }) + .first() + .click(); + + await page + .getByRole("combobox") + .filter({ hasText: `${prefix} EVENT TYPE 2` }) + .click(); + + // Set date and time + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateString = tomorrow.toISOString().split("T")[0] ?? ""; + + await page.getByLabel("Start Date").fill(dateString); + await fillTimeInput(page, "startTime", "0600A"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await fillTimeInput(page, "endTime", "0700A"); + }, + }); + }); + + test("edit the new Event", async ({ page }) => { + await goToSection(page, "Events"); + await editItemAndVerify({ + page, + name: "EVENT", + order: 2, + prefix, + }); + }); + + // Delete all test items in reverse order + test("delete the Event", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Events", + name: `${prefix} EVENT 2`, + }); + }); + + test("delete the Event Type", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Event Types", + name: `${prefix} EVENT TYPE 2`, + }); + }); + + test("delete the new Location", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Locations", + name: `${prefix} LOCATION 2`, + }); + }); + + test("delete the new AO", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "AOs", + name: `${prefix} AO 2`, + }); + }); + + test("delete the new Region", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Regions", + name: `${prefix} REGION 2`, + }); + }); + + test("delete the new Area", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Areas", + name: `${prefix} AREA 2`, + }); + }); + + test("delete the new Sector", async ({ page }) => { + await deleteItemAndVerify({ + page, + section: "Sectors", + name: `${prefix} SECTOR 2`, + }); + }); + + test("clean up test data", async ({ page }) => { + await cleanupTestData({ page, prefix }); + }); +}); diff --git a/apps/map/e2e/helpers.ts b/apps/map/e2e/helpers.ts new file mode 100644 index 00000000..af7fd2a8 --- /dev/null +++ b/apps/map/e2e/helpers.ts @@ -0,0 +1,264 @@ +import type { BrowserContext, Locator, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +export const testLogger = (context: string) => ({ + log: (action: string, data?: Record) => { + console.log(context, { + action, + ...data, + }); + }, +}); + +export const waitForMap = async (page: Page, timeout = 10000) => { + const logger = testLogger("waitForMap"); + logger.log("Waiting for map to load"); + + try { + await page.waitForSelector('[aria-label="Map"]', { + timeout, + state: "visible", + }); + logger.log("Map loaded successfully"); + } catch (error) { + logger.log("Map failed to load", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +}; + +export const setupTestEnvironment = async ( + page: Page, + context: BrowserContext, +) => { + const logger = testLogger("setupTestEnvironment"); + logger.log("Setting up test environment"); + + // Grant location permissions + await context.grantPermissions(["geolocation"]); +}; + +export const setupAdminTestEnvironment = async (page: Page) => { + await page.goto("/?lat=36.211104&lng=-81.660849&zoom=3"); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Sign in (Dev Mode)" }).click(); +}; + +export const turnOnEditMode = async (page: Page) => { + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Edit" }).click(); + await page.getByRole("button", { name: "Close" }).click(); +}; + +export const goToAdminPortal = async (page: Page) => { + await page.goto("/admin"); + // await page.getByRole("button", { name: "Settings" }).click(); + // await page.getByRole("button", { name: "Admin Portal" }).click(); +}; + +export const teardownTestEnvironment = async (context: BrowserContext) => { + const logger = testLogger("teardownTestEnvironment"); + logger.log("Tearing down test environment"); + + // Stop tracing and save it + await context.tracing.stop({ path: "test-results/trace.zip" }); +}; + +export const setStore = async ( + page: Page, + store: "map-store", + value: Record, +) => { + await page.addInitScript(` + window.localStorage.setItem('${store}', JSON.stringify(${JSON.stringify(value)})); + `); +}; + +/** + * Cleans up test data from the database. + * This function should be called at the beginning of test suites to ensure a clean state. + */ +export const cleanupTestData = async (params: { + page: Page; + prefix: "SUPER TEST" | "MAP TEST"; +}) => { + const { page, prefix } = params; + const logger = testLogger("cleanupTestData"); + logger.log("Cleaning up test data"); + + // First navigate to admin portal (if not already there) + await goToAdminPortal(page); + + // Delete any test items that might exist from previous test runs + await deleteTestItems(page, "Events", prefix); + await deleteTestItems(page, "Event Types", prefix); + await deleteTestItems(page, "Locations", prefix); + await deleteTestItems(page, "AOs", prefix); + await deleteTestItems(page, "Regions", prefix); + await deleteTestItems(page, "Areas", prefix); + await deleteTestItems(page, "Sectors", prefix); + + logger.log("Test data cleanup completed"); +}; + +const waitOrMoveOn = async (locator: Locator, timeoutMs = 1000) => { + try { + await locator.first().waitFor({ timeout: timeoutMs, state: "attached" }); + return await locator.all(); + } catch (error) { + // Timeout occurred or element not found + return []; + } +}; + +/** + * Helper function to delete all items containing the search term + * from a specific section in the admin portal + */ +async function deleteTestItems( + page: Page, + section: string, + searchTerm: string, +) { + const logger = testLogger(`deleteTestItems:${section}`); + logger.log("Deleting test items", { section, searchTerm }); + + // Navigate to the correct section + await page.getByRole("link", { name: section }).click(); + await page.waitForTimeout(250); + + // Search for test items + await page.getByRole("textbox", { name: "Search rows..." }).click(); + await page.getByRole("textbox", { name: "Search rows..." }).fill(searchTerm); + + // Get count of matching items + const items = await waitOrMoveOn(page.getByText(new RegExp(searchTerm, "i"))); + logger.log(`Found ${items.length} items to delete`, { count: items.length }); + + // Delete all found items + for (let i = 0; i < items.length; i++) { + try { + // Items list changes after each deletion, so we need to search again + const currentItems = await page + .getByText(new RegExp(searchTerm, "i")) + .all(); + if (currentItems.length === 0) break; + + // Click the first item - currentItems[0] is checked above so it's safe to use + const firstItem = currentItems[0]; + if (firstItem) { + await firstItem.click(); + + // Click delete button and confirm + await page.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete" }).click(); + + // Wait for confirmation + await page.waitForTimeout(500); + + logger.log(`Deleted item ${i + 1}`, { + remaining: currentItems.length - 1, + }); + } + } catch (error) { + logger.log("Error deleting item", { + error: error instanceof Error ? error.message : String(error), + index: i, + }); + break; + } + } +} + +export const fillTimeInput = async ( + page: Page, + inputId: string, + time: string, +) => { + await page.locator(`#${inputId}`).click(); + for (const char of time) { + await page.keyboard.press(char); + } +}; + +export const searchTableRowsForText = async ( + page: Page, + text: string, + click?: boolean, +) => { + await page.getByRole("textbox", { name: "Search rows..." }).click(); + await page.getByRole("textbox", { name: "Search rows..." }).fill(text); + await expect( + page.locator("tbody tr").first().locator("td").first(), + ).toHaveText(text); + if (click) { + await page.getByText(text).click(); + } +}; + +export const verifyTableRowDoesNotExist = async (page: Page, text: string) => { + await page.getByRole("textbox", { name: "Search rows..." }).click(); + await page.getByRole("textbox", { name: "Search rows..." }).fill(text); + await expect( + page.locator("tbody tr").first().locator("td").first(), + ).not.toHaveText(text); +}; + +export const goToSection = async (page: Page, section: string) => { + await page.getByRole("link", { name: section }).click(); + await expect(page.getByRole("heading", { name: section })).toBeVisible(); +}; + +export const addNewItemAndVerify = async (params: { + page: Page; + section: string; + extraSteps?: () => Promise; + prefix: "SUPER TEST" | "MAP TEST"; +}) => { + const { page, section, extraSteps, prefix } = params; + await page.getByRole("button", { name: `Add ${section}` }).click(); + await expect( + page.getByRole("heading", { name: `Add ${section}` }), + ).toBeVisible(); + await page + .getByRole("textbox", { name: "Name" }) + .fill(`${prefix} ${section.toUpperCase()}`); + await extraSteps?.(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await searchTableRowsForText(page, `${prefix} ${section.toUpperCase()}`); +}; + +export const editItemAndVerify = async (params: { + page: Page; + name: string; + order: number; + prefix: "SUPER TEST" | "MAP TEST"; + extraSteps?: () => Promise; +}) => { + const { page, name, order, prefix, extraSteps } = params; + const oldName = `${prefix} ${name}`; + const newName = `${prefix} ${name} ${order}`; + await searchTableRowsForText(page, oldName, true); + await expect(page.getByRole("textbox", { name: "Name" })).toHaveValue( + oldName, + ); + await page.getByRole("textbox", { name: "Name" }).fill(newName); + await extraSteps?.(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await searchTableRowsForText(page, newName); +}; + +export const deleteItemAndVerify = async (params: { + page: Page; + section: string; + name: string; +}) => { + const { page, section, name } = params; + await goToSection(page, section); + await searchTableRowsForText(page, name, true); + await page.getByRole("button", { name: "Delete" }).click(); + await page.waitForSelector("text=Are you sure"); + await page.getByRole("button", { name: "Delete" }).click(); + await verifyTableRowDoesNotExist(page, name); +}; diff --git a/apps/map/e2e/no-auth/edit.spec.ts b/apps/map/e2e/no-auth/edit.spec.ts new file mode 100644 index 00000000..279f2f3b --- /dev/null +++ b/apps/map/e2e/no-auth/edit.spec.ts @@ -0,0 +1,475 @@ +import path from "path"; +import { expect, test } from "@playwright/test"; + +import { SIDEBAR_WIDTH } from "@acme/shared/app/constants"; +// TODO: Remove all wait for timeouts + +import { TestId } from "@acme/shared/common/enums"; + +import { turnOnEditMode } from "../helpers"; + +const prefix = "EDIT FLOW"; + +/** + * This test covers all map actions for admin: create, move, edit, add, and move event/AO. + */ +test.describe("Admin Map Actions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/?lat=31.659308&lng=-132.955330&zoom=8"); + await turnOnEditMode(page); + await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 }); + }); + + test("New location, AO, & event", async ({ page }) => { + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + await page.mouse.click( + (width - SIDEBAR_WIDTH) / 2 + SIDEBAR_WIDTH, + height / 2, + ); + await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({ + timeout: 2000, + }); + await page + .getByRole("button", { name: "New location, AO, & event", exact: true }) + .click(); + await expect(page.getByText("New Location, AO, & Event")).toBeVisible({ + timeout: 2000, + }); + await page.getByRole("combobox").first().click(); + await expect( + page + .getByRole("dialog", { name: "New Location, AO & Event" }) + .getByText("Boone"), + ).toBeVisible({ timeout: 2000 }); + await page.keyboard.type("Boone"); + await expect(page.getByRole("option", { name: "Boone" })).toBeVisible({ + timeout: 2000, + }); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.locator('input[name="locationAddress"]').fill("100 Test St"); + await page.locator('input[name="locationCity"]').fill("Testville"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("11111"); + await page.locator("#locationCountry").click(); + await page.getByRole("option", { name: "United States" }).click(); + + // Get the existing lat and lng values from the inputs + const latInput = page.locator('input[name="locationLat"]'); + const lngInput = page.locator('input[name="locationLng"]'); + const latValueStr = await latInput.inputValue(); + const lngValueStr = await lngInput.inputValue(); + const latValue = parseFloat(latValueStr); + const lngValue = parseFloat(lngValueStr); + const newLat = (isNaN(latValue) ? 0 : latValue) + 2; + const newLng = (isNaN(lngValue) ? 0 : lngValue) + 2; + await latInput.fill(newLat.toString()); + await lngInput.fill(newLng.toString()); + + await page + .locator('textarea[name="locationDescription"]') + .fill("First test location"); + await page.getByText("AO Details:").scrollIntoViewIfNeeded(); + await page.locator('input[name="aoName"]').fill(`${prefix} AO1`); + await page.locator('input[name="aoWebsite"]').fill("https://ao1.com"); + const filePath = path.resolve(process.cwd(), "e2e/tests/image.png"); + await page.locator('input[type="file"]').setInputFiles(filePath); + await page.getByText("Event Details:").scrollIntoViewIfNeeded(); + await page.locator('input[name="eventName"]').fill(`${prefix} EVENT1`); + await page.locator("#eventDayOfWeek").click(); + await page.getByRole("option", { name: "Monday" }).click(); + await page.locator("#eventStartTime").click(); + await page.keyboard.type("0600A"); + await page.locator("#eventEndTime").click(); + await page.keyboard.type("0700A"); + await page.getByRole("button", { name: "Select event types" }).click(); + await page.getByRole("option").first().click(); + await page + .locator('textarea[name="eventDescription"]') + .fill("First event description"); + await page.getByText("Contact Information:").scrollIntoViewIfNeeded(); + await page.locator('input[name="submittedBy"]').fill("admin1@example.com"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Move existing AO here", async ({ page }) => { + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + await page.mouse.click(width / 2 + 50, height / 2 + 50); + await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({ + timeout: 2000, + }); + await page + .getByRole("button", { name: "Move existing AO here", exact: true }) + .click(); + await page.waitForTimeout(500); + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.getByRole("combobox").nth(1).click(); + await page.keyboard.type(`${prefix} AO1`); + await page.waitForTimeout(500); + await page + .getByRole("option", { name: `${prefix} AO1` }) + .first() + .click(); + await page.locator('input[name="locationAddress"]').fill("200 Test St"); + await page.locator('input[name="locationCity"]').fill("Moved City"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("22222"); + await page.locator("#locationCountry").click(); + await page.getByRole("option", { name: "United States" }).click(); + await page + .locator('textarea[name="locationDescription"]') + .fill("Moved AO location"); + + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Edit AO details", async ({ page }) => { + // Open AO panel + const aoButton = page + .getByRole("button", { name: `${prefix} AO1` }) + .first(); + await expect(aoButton).toBeVisible({ timeout: 10000 }); + await aoButton.click(); + await page.waitForTimeout(1000); + // Open edit dropdown + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 1000 }); + // Click Edit AO details + await page.getByRole("button", { name: "Edit AO:" }).first().click(); + await page + .getByRole("menuitem", { name: "Edit AO details" }) + .first() + .click(); + await page.locator('input[name="aoName"]').fill(`${prefix} AO1 Updated`); + await page + .locator('input[name="aoWebsite"]') + .fill("https://ao1-updated.com"); + const filePath = path.resolve(process.cwd(), "e2e/tests/image.png"); + await page.locator('input[type="file"]').setInputFiles(filePath); + await page.locator('input[name="locationAddress"]').fill("201 Test St"); + await page.locator('input[name="locationCity"]').fill("Updated City"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("33333"); + await page.locator("#locationCountry").click(); + await page.getByRole("option", { name: "Mexico" }).click(); + await page + .locator('textarea[name="locationDescription"]') + .fill("Updated AO location"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Move to different region", async ({ page }) => { + // Open AO panel + const aoButton = page + .getByRole("button", { name: `${prefix} AO1 Updated` }) + .first(); + await expect(aoButton).toBeVisible({ timeout: 10000 }); + await aoButton.click(); + await page.waitForTimeout(1000); + // Open edit dropdown + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 1000 }); + await page.getByRole("button", { name: "Edit AO:" }).first().click(); + // Click Move to different region + await page + .getByRole("menuitem", { name: "Move to different region" }) + .click(); + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Charlotte"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Charlotte" }).first().click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Edit workout details", async ({ page }) => { + // Open AO panel and select event + const aoButton = page + .getByRole("button", { name: `${prefix} AO1 Updated` }) + .first(); + await expect(aoButton).toBeVisible({ timeout: 10000 }); + await aoButton.click(); + await page.waitForTimeout(1000); + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 1000 }); + + const eventButton = page + .getByRole("button", { name: `${prefix} EVENT1` }) + .first(); + await expect(eventButton).toBeVisible({ timeout: 10000 }); + await eventButton.click(); + await page.waitForTimeout(1000); + // Open edit dropdown + // Click Edit workout details + await page.getByRole("menuitem", { name: "Edit workout details" }).click(); + await page + .locator('input[name="eventName"]') + .fill(`${prefix} EVENT1 Updated`); + await page.locator("#eventDayOfWeek").click(); + await page.getByRole("option", { name: "Friday" }).click(); + await page.locator("#eventStartTime").click(); + await page.keyboard.type("0630A"); + await page.locator("#eventEndTime").click(); + await page.keyboard.type("0730A"); + await page + .locator( + 'div:has-text("Event Types") ~ div button[aria-haspopup="dialog"]', + ) + .click(); + await page.getByRole("option").nth(1).click(); + await page + .locator('textarea[name="eventDescription"]') + .fill("Updated event description"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("New location, AO, & event (2)", async ({ page }) => { + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + await page.mouse.click(width / 2 - 50, height / 2 - 50); + await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({ + timeout: 5000, + }); + await page + .getByRole("button", { name: "New location, AO, & event", exact: true }) + .click(); + await page.waitForTimeout(500); + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.locator('input[name="locationAddress"]').fill("300 Test St"); + await page.locator('input[name="locationCity"]').fill("Testopolis"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("44444"); + await page + .locator('textarea[name="locationDescription"]') + .fill("Second test location"); + await page.getByText("AO Details:").scrollIntoViewIfNeeded(); + await page.locator('input[name="aoName"]').fill(`${prefix} AO2`); + await page.locator('input[name="aoWebsite"]').fill("https://ao2.com"); + const filePath = path.resolve(process.cwd(), "e2e/tests/image.png"); + await page.locator('input[type="file"]').setInputFiles(filePath); + await page.getByText("Event Details:").scrollIntoViewIfNeeded(); + await page.locator('input[name="eventName"]').fill(`${prefix} EVENT2`); + await page.locator("#eventDayOfWeek").click(); + await page.getByRole("option", { name: "Tuesday" }).click(); + await page.locator("#eventStartTime").click(); + await page.keyboard.type("0600A"); + await page.locator("#eventEndTime").click(); + await page.keyboard.type("0700A"); + await page.getByRole("button", { name: "Select event types" }).click(); + await page.getByRole("option").first().click(); + await page + .locator('textarea[name="eventDescription"]') + .fill("Second event description"); + await page.getByText("Contact Information:").scrollIntoViewIfNeeded(); + await page.locator('input[name="submittedBy"]').fill("admin2@example.com"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Add workout", async ({ page }) => { + // Open AO2 panel + const aoButton = page + .getByRole("button", { name: `${prefix} AO2` }) + .first(); + await expect(aoButton).toBeVisible({ timeout: 10000 }); + await aoButton.click(); + await page.waitForTimeout(1000); + // Click Add Workout to AO + await page.getByRole("button", { name: "Add Workout to AO" }).click(); + await page.locator('input[name="eventName"]').fill(`${prefix} EVENT3`); + await page.locator("#eventDayOfWeek").click(); + await page.getByRole("option", { name: "Wednesday" }).click(); + await page.locator("#eventStartTime").click(); + await page.keyboard.type("0600A"); + await page.locator("#eventEndTime").click(); + await page.keyboard.type("0700A"); + await page.getByRole("button", { name: "Select event types" }).click(); + await page.getByRole("option").first().click(); + await page + .locator('textarea[name="eventDescription"]') + .fill("Third event description"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Move to different AO", async ({ page }) => { + // Open AO2 panel and select event + const aoButton = page + .getByRole("button", { name: `${prefix} AO2` }) + .first(); + await expect(aoButton).toBeVisible({ timeout: 10000 }); + await aoButton.click(); + await page.waitForTimeout(1000); + const eventButton = page + // .getByRole("button", { name: `${prefix} EVENT3` }) + .getByText("Wednesday 6AM - 7AM (60min)") + .first(); + await expect(eventButton).toBeVisible({ timeout: 10000 }); + await eventButton.click(); + + const editButton = page + .getByRole("button", { name: `Edit Workout: ${prefix} EVENT3` }) + .first(); + await expect(editButton).toBeVisible({ timeout: 10000 }); + await editButton.click(); + + // Open edit dropdown + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 1000 }); + + // Click Move to different AO + await page.getByRole("menuitem", { name: "Move to different AO" }).click(); + await page.getByRole("combobox").first().click(); + await page.getByPlaceholder("Select Region").click(); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.getByRole("combobox").nth(1).click(); + await page.getByPlaceholder("Select AO").click(); + await page.keyboard.type(`${prefix} AO1 Updated`); + await page.waitForTimeout(500); + await page + .getByRole("option", { name: `${prefix} AO1 Updated (Boone)` }) + .first() + .click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("Move existing event here", async ({ page }) => { + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + await page.mouse.click(width / 2 + 100, height / 2 + 100); + await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({ + timeout: 5000, + }); + await page + .getByRole("button", { name: "Move existing event here", exact: true }) + .click(); + await page.waitForTimeout(500); + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.getByRole("combobox").nth(1).click(); + await page.keyboard.type(`${prefix} AO2 (Boone)`); + await page.waitForTimeout(500); + await page + .getByRole("option", { name: `${prefix} AO2 (Boone)` }) + .first() + .click(); + await page.getByRole("combobox").nth(2).click(); + await page.keyboard.type(`${prefix} EVENT2`); + await page.waitForTimeout(500); + await page + .getByRole("option", { name: `${prefix} EVENT2` }) + .first() + .click(); + await page.locator('input[name="locationAddress"]').fill("400 Test St"); + await page.locator('input[name="locationCity"]').fill("EventMoved City"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("55555"); + await page + .locator('textarea[name="locationDescription"]') + .fill("Event moved location"); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + // Ensure the update modal closes + await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 }); + }); + + test("CLEANUP: delete created AOs", async ({ page }) => { + const maxAttempts = 10; + let attempt = 0; + while (attempt < maxAttempts) { + await page.reload(); + await page.waitForTimeout(1000); // Give time for page to load + // Find all AO buttons with the prefix in their name + const nearbyLocations = page.getByTestId(TestId.NEARBY_LOCATIONS); + const aoButtons = await nearbyLocations.locator("button").all(); + const aoButtonsWithPrefix = []; + for (const btn of aoButtons) { + const name = await btn.textContent(); + if (name?.includes(prefix)) { + aoButtonsWithPrefix.push(btn); + } + } + if (aoButtonsWithPrefix.length === 0) { + break; + } + for (const aoButton of aoButtonsWithPrefix) { + if (await aoButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await aoButton.click(); + // Open edit dropdown + // wait until the edit button is visible + const editButton = page + .getByRole("button", { name: "Edit AO:" }) + .first(); + await editButton.waitFor({ state: "visible", timeout: 1000 }); + await editButton.click(); + // Click Delete AO + const deleteMenuItem = page + .getByRole("menuitem", { name: "Delete this AO", exact: true }) + .first(); + if ( + await deleteMenuItem.isVisible({ timeout: 1000 }).catch(() => false) + ) { + await deleteMenuItem.click(); + // Confirm deletion in modal/dialog + const confirmButton = page + .getByRole("button", { name: /Delete|Confirm/i }) + .first(); + if ( + await confirmButton + .isVisible({ timeout: 1000 }) + .catch(() => false) + ) { + await confirmButton.click(); + await page.waitForTimeout(1000); + } + } + } + } + attempt++; + } + }); +}); + +// TODO: test deleting a workout first. then delete an AO diff --git a/apps/map/tests/inital-load.spec.ts b/apps/map/e2e/no-auth/initial-load.spec.ts similarity index 97% rename from apps/map/tests/inital-load.spec.ts rename to apps/map/e2e/no-auth/initial-load.spec.ts index be1fcefa..567e289d 100644 --- a/apps/map/tests/inital-load.spec.ts +++ b/apps/map/e2e/no-auth/initial-load.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { TestId } from "@acme/shared/common/enums"; -import { setStore, setupTestEnvironment, waitForMap } from "./helpers"; +import { setStore, setupTestEnvironment, waitForMap } from "../helpers"; test.describe("Initial Load", () => { test.beforeEach(async ({ page, context }) => { diff --git a/apps/map/tests/manage-event-workflow.spec.ts b/apps/map/e2e/no-auth/manage-event-workflow.spec.ts similarity index 77% rename from apps/map/tests/manage-event-workflow.spec.ts rename to apps/map/e2e/no-auth/manage-event-workflow.spec.ts index ee20592d..cb9b4662 100644 --- a/apps/map/tests/manage-event-workflow.spec.ts +++ b/apps/map/e2e/no-auth/manage-event-workflow.spec.ts @@ -2,17 +2,34 @@ import { expect, test } from "@playwright/test"; import { TestId } from "@acme/shared/common/enums"; -import { setupAdminTestEnvironment } from "./helpers"; +import { + cleanupTestData, + setupAdminTestEnvironment, + turnOnEditMode, +} from "../helpers"; + +test.describe("Manage Workout Workflow", () => { + test.beforeAll(async ({ browser }) => { + // Create a new page for cleanup only + const context = await browser.newContext(); + const page = await context.newPage(); + + // Clean up any leftover test data before starting tests + await cleanupTestData({ page, prefix: "MAP TEST" }); + + // Close the context when done + await context.close(); + }); -test.describe("Manage Event Workflow", () => { test.beforeEach(async ({ page }) => { await setupAdminTestEnvironment(page); + await turnOnEditMode(page); }); test("Find test AO if it exists and delete it", async ({ page }) => { const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT); await mapSearchboxInput.click(); - await mapSearchboxInput.fill("Test event"); + await mapSearchboxInput.fill("Test workout"); await page.waitForTimeout(2000); const searchResults = page.getByTestId( @@ -22,7 +39,7 @@ test.describe("Manage Event Workflow", () => { const testAoButtonText = await testAoButton.textContent(); console.log("testAoButtonText", testAoButtonText); if ( - !testAoButtonText?.toLowerCase().includes("test event") || + !testAoButtonText?.toLowerCase().includes("test workout") || !testAoButtonText?.toLowerCase().includes("workout") ) { console.log("Exiting: testAoButtonText", testAoButtonText); @@ -31,14 +48,14 @@ test.describe("Manage Event Workflow", () => { await testAoButton.click(); const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP); await expect(selectedItem).toBeVisible(); - await expect(selectedItem).toContainText("Test event", { + await expect(selectedItem).toContainText("Test workout", { ignoreCase: true, }); await selectedItem.click(); const panel = page.getByTestId(TestId.PANEL); await expect(panel).toBeVisible(); - await expect(panel).toContainText("Test event", { + await expect(panel).toContainText("Test workout", { ignoreCase: true, }); @@ -97,24 +114,26 @@ test.describe("Manage Event Workflow", () => { const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT); await mapSearchboxInput.click(); - await mapSearchboxInput.fill("test event"); + await mapSearchboxInput.fill("test workout"); - await page.getByRole("button", { name: "item Test Event Workout" }).click(); + await page + .getByRole("button", { name: "item Test Workout Workout" }) + .click(); const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP); await expect(selectedItem).toBeVisible(); - await expect(selectedItem).toContainText("Test Event"); + await expect(selectedItem).toContainText("Test Workout"); await selectedItem.click(); const panel = page.getByTestId(TestId.PANEL); await expect(panel).toBeVisible(); - await expect(panel).toContainText("Test Event"); + await expect(panel).toContainText("Test Workout"); const editButton = page .getByRole("button", { name: "Edit Workout" }) .first(); await editButton.click(); await page.locator('input[name="eventName"]').click(); - await page.locator('input[name="eventName"]').fill("Test Event 1234"); + await page.locator('input[name="eventName"]').fill("Test Workout 1234"); await page.waitForTimeout(500); await page.getByRole("button", { name: "Save Changes" }).click(); await page.waitForTimeout(2000); // wait for the map to update diff --git a/apps/map/e2e/no-auth/nearby-locations.spec.ts b/apps/map/e2e/no-auth/nearby-locations.spec.ts new file mode 100644 index 00000000..08512c2e --- /dev/null +++ b/apps/map/e2e/no-auth/nearby-locations.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; + +import { TestId } from "@acme/shared/common/enums"; + +// Test: Search for 'TEST EVENT' in the search bar + +const prefix = "EDIT TEST"; + +test("searches for TEST EVENT in the search bar", async ({ page }) => { + // Navigate to the site + await page.goto("http://localhost:3000"); + + // Click the search bar (try common selectors) + + // Search and open the created item + const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT); + await mapSearchboxInput.click(); + await mapSearchboxInput.fill(prefix); + await page.waitForTimeout(2000); + const searchResult = page + .getByRole("button", { + name: `item ${prefix} Event Workout (F3 Boone)`, + exact: true, + }) + .first(); + await expect(searchResult).toBeVisible({ timeout: 10000 }); + await searchResult.click(); + await page.waitForTimeout(1000); + + // await page.setViewportSize({ width: 1280, height: 720 }); + + const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP); + await expect(selectedItem).toBeVisible({ timeout: 5000 }); + await selectedItem.click(); + await page.waitForTimeout(1000); + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 5000 }); + await expect(panel).toContainText(`${prefix} AO`, { timeout: 5000 }); + await expect(panel).toContainText(`${prefix} EVENT`, { timeout: 5000 }); +}); diff --git a/apps/map/e2e/no-auth/update-actions.spec.ts b/apps/map/e2e/no-auth/update-actions.spec.ts new file mode 100644 index 00000000..b2c21fbe --- /dev/null +++ b/apps/map/e2e/no-auth/update-actions.spec.ts @@ -0,0 +1,314 @@ +import path from "path"; +import { expect, test } from "@playwright/test"; + +import { TestId } from "@acme/shared/common/enums"; + +import { turnOnEditMode } from "../helpers"; + +/** + * As an authenticated user, go through all the update actions in the map and verify that the updates are correct + * + * 1. Go to the map + * 2. turn on edit mode + * 3. click on the map and create a new location with an event for the Boone region + * 4. do the other new marker actions + * 5. Go to this new ao + * 6. do the update actions allowed there + */ + +test.describe("Update Actions", () => { + test.beforeAll(async ({ browser }) => { + // Create a new page for cleanup only + const context = await browser.newContext(); + const _page = await context.newPage(); + + // Clean up any leftover test data before starting tests + // Turn this off for now to improve test speed + // await cleanupTestData(page, "MAP TEST"); + + // Close the context when done + await context.close(); + }); + + test.beforeEach(async ({ page }) => { + // We already have an auth state saved so we don't need to set up an auth environment + await page.goto("/"); + await turnOnEditMode(page); + }); + + test("should create a new location with AO and event", async ({ page }) => { + console.log("Starting create and update test"); + // Wait for the map to be fully loaded + await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 }); + + // Get the map dimensions + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + console.log("Map dimensions", { width, height }); + + // Click on the map to create a new location + await page.mouse.click(width / 2, height / 2); + + // Verify the update pane marker is visible + const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER); + await expect(updatePaneMarker).toBeVisible({ timeout: 5000 }); + + // Click on the "New location, AO, & event" button + const newLocationButton = page.getByRole("button", { + name: "New location, AO, & event", + exact: true, + }); + await expect(newLocationButton).toBeVisible({ timeout: 5000 }); + await newLocationButton.click(); + await page.waitForTimeout(1000); + + // Fill in the form fields + + // 1. Fill in Region Details + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.waitForTimeout(500); + + // 2. Fill in Location Details + await page.locator('input[name="locationAddress"]').fill("123 Test Street"); + await page.locator('input[name="locationCity"]').fill("Test City"); + await page.locator('input[name="locationState"]').fill("NC"); + await page.locator('input[name="locationZip"]').fill("12345"); + await page + .locator('textarea[name="locationDescription"]') + .fill("This is a test location description"); + + // 3. Fill in Event Details + await page.locator('input[name="eventName"]').fill("MAP TEST Event"); + + // Select day of week + await page.getByText("Day of Week").scrollIntoViewIfNeeded(); + await page.locator("#eventDayOfWeek").click(); + await page.getByRole("option", { name: "Wednesday" }).click(); + + // Set times + await page.locator("#eventStartTime").click(); + await page.keyboard.type("0600A"); + await page.locator("#eventEndTime").click(); + await page.keyboard.type("0700A"); + + // Select event types + await page.getByRole("button", { name: "Select event types" }).click(); + await page.waitForTimeout(500); + const eventTypeOption = page.getByRole("option").first(); + await eventTypeOption.click(); + + // Add event description + await page + .locator('textarea[name="eventDescription"]') + .fill("This is a test event description"); + + // 4. Fill in AO Details + await page.getByText("AO Details:").scrollIntoViewIfNeeded(); + await page.locator('input[name="aoName"]').fill("MAP TEST AO"); + await page.locator('input[name="aoWebsite"]').fill("https://example.com"); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.locator('input[name="aoLogo"]').click(); + const fileChooser = await fileChooserPromise; + // Base is apps/nextjs + const logoPath = path.join(process.cwd(), "public/f3_logo.png"); + console.log("logoPath", logoPath); + await fileChooser.setFiles("public/f3_logo.png"); + + // 5. Fill Contact Details + await page.locator('input[name="submittedBy"]').fill("test@example.com"); + + // Submit the form + await page.getByTestId(TestId.UPDATE_MODAL_SUBMIT_BUTTON).click(); + await page.waitForTimeout(2000); + + // Search for the newly created item + const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT); + await mapSearchboxInput.click(); + await mapSearchboxInput.fill("MAP TEST"); + await page.waitForTimeout(2000); + + // Click on the search result + const searchResult = page + .getByRole("button", { name: /MAP TEST/i }) + .first(); + await expect(searchResult).toBeVisible({ timeout: 10000 }); + await searchResult.click(); + await page.waitForTimeout(1000); + + // Verify the selected item is visible + const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP); + await expect(selectedItem).toBeVisible({ timeout: 5000 }); + await expect(selectedItem).toContainText("MAP TEST", { timeout: 5000 }); + + // Click on the selected item to open the panel + await selectedItem.click(); + await page.waitForTimeout(1000); + + // Verify the panel is visible with our data + const panel = page.getByTestId(TestId.PANEL); + await expect(panel).toBeVisible({ timeout: 5000 }); + await expect(panel).toContainText("MAP TEST AO", { timeout: 5000 }); + await expect(panel).toContainText("MAP TEST Event", { timeout: 5000 }); + + // Edit the item + const editButton = page.getByRole("button", { name: "Edit" }).first(); + await editButton.click(); + await page.waitForTimeout(1000); + + // Update the name + const eventNameInput = page.locator('input[name="eventName"]'); + await eventNameInput.click(); + await eventNameInput.fill("MAP TEST Event Updated"); + + // Save the changes + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.waitForTimeout(2000); + + // Verify the changes were saved + await expect(panel).toContainText("MAP TEST Event Updated", { + timeout: 5000, + }); + + // Delete the item + const deleteButton = page.getByRole("button", { name: "Delete" }).first(); + await deleteButton.click(); + await page.waitForTimeout(1000); + + await page.getByRole("button", { name: "Delete" }).click(); + await page.waitForTimeout(2000); + + // Verify it was deleted + await mapSearchboxInput.click(); + await mapSearchboxInput.fill("MAP TEST Event Updated"); + await page.waitForTimeout(2000); + + try { + const count = await page + .getByRole("button", { name: /MAP TEST Event Updated/i }) + .count(); + if (count > 0) { + await expect( + page.getByRole("button", { name: /MAP TEST Event Updated/i }), + ).not.toBeVisible({ timeout: 5000 }); + } + } catch (e) { + console.log("Button not found, which is expected if item was deleted"); + } + }); + + test("should test move AO to new location", async ({ page }) => { + // Wait for the map to be fully loaded + await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 }); + + // Get the map dimensions + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + + // Click on a different spot on the map + await page.mouse.click(width / 2 + 100, height / 2 - 100); + await page.waitForTimeout(1000); + + // Verify the update pane marker is visible + const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER); + await expect(updatePaneMarker).toBeVisible({ timeout: 5000 }); + + // Click on the marker to open the update pane + await updatePaneMarker.click(); + await page.waitForTimeout(1000); + + // Click on "Move existing AO here" button + const moveAOButton = page.getByRole("button", { + name: "Move existing AO here", + exact: true, + }); + await expect(moveAOButton).toBeVisible({ timeout: 5000 }); + await moveAOButton.click(); + await page.waitForTimeout(1000); + + // Select region (just to test the UI, not actually submitting) + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.waitForTimeout(500); + + // Try to select an AO + try { + await page.getByText("AO Selection:").scrollIntoViewIfNeeded(); + const aoSelector = page.getByRole("combobox").nth(1); + if (await aoSelector.isVisible({ timeout: 2000 })) { + await aoSelector.click(); + await page.keyboard.type("Yarak"); + await page.waitForTimeout(1000); + } + } catch (e) { + console.log("Could not select AO"); + } + + // Fill location details + await page.locator('input[name="locationAddress"]').fill("456 Move Street"); + await page.locator('input[name="locationCity"]').fill("Move City"); + + // Cancel the operation + await page.getByRole("button", { name: "Cancel" }).click(); + await page.waitForTimeout(1000); + }); + + test("should test move event to new location", async ({ page }) => { + // Wait for the map to be fully loaded + await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 }); + + // Get the map dimensions + const mapContainer = await page.$('[aria-label="Map"]'); + const { width, height } = (await mapContainer?.boundingBox()) ?? { + width: 0, + height: 0, + }; + + // Click on yet another spot on the map + await page.mouse.click(width / 2 - 100, height / 2 + 100); + await page.waitForTimeout(1000); + + // Verify the update pane marker is visible + const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER); + await expect(updatePaneMarker).toBeVisible({ timeout: 5000 }); + + // Click on the marker to open the update pane + await updatePaneMarker.click(); + await page.waitForTimeout(1000); + + // Click on "Move existing event here" button + const moveEventButton = page.getByRole("button", { + name: "Move existing event here", + exact: true, + }); + await expect(moveEventButton).toBeVisible({ timeout: 5000 }); + await moveEventButton.click(); + await page.waitForTimeout(1000); + + // Select region (just to test the UI, not actually submitting) + await page.getByRole("combobox").first().click(); + await page.keyboard.type("Boone"); + await page.waitForTimeout(500); + await page.getByRole("option", { name: "Boone" }).first().click(); + await page.waitForTimeout(500); + + // Fill in some location details + await page + .locator('input[name="locationAddress"]') + .fill("789 Event Move Street"); + + // Cancel the operation + await page.getByRole("button", { name: "Cancel" }).click(); + await page.waitForTimeout(1000); + }); +}); diff --git a/apps/map/e2e/setup/auth.spec.ts b/apps/map/e2e/setup/auth.spec.ts new file mode 100644 index 00000000..8a623cc3 --- /dev/null +++ b/apps/map/e2e/setup/auth.spec.ts @@ -0,0 +1,32 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { expect, test as setup } from "@playwright/test"; + +// Convert the URL into a file path +const __filename = fileURLToPath(import.meta.url); +// Get the directory name +const __dirname = path.dirname(__filename); + +// Create the auth directory if it doesn't exist +const authDir = path.join(__dirname, ".auth"); +if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); +} + +const authFile = path.join(__dirname, ".auth/user.json"); + +setup("authenticate", async ({ page }) => { + // Perform authentication steps + await page.goto("/?lat=36.211104&lng=-81.660849&zoom=3"); + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Sign in (Dev Mode)" }).click(); + + // Wait to ensure we're authenticated + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + + // Save authentication state to file + await page.context().storageState({ path: authFile }); + + console.log("Authentication completed and state saved to:", authFile); +}); diff --git a/apps/map/e2e/tests/image.png b/apps/map/e2e/tests/image.png new file mode 100644 index 00000000..07839be7 Binary files /dev/null and b/apps/map/e2e/tests/image.png differ diff --git a/apps/map/package.json b/apps/map/package.json index e44bd8a2..fef7999a 100644 --- a/apps/map/package.json +++ b/apps/map/package.json @@ -7,6 +7,7 @@ "build": "pnpm with-env next build", "clean": "rm -rf .turbo node_modules .next", "dev": "TZ=UTC pnpm with-env next dev", + "dev:inspect": "NODE_OPTIONS='--inspect' TZ=UTC pnpm with-env next dev", "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore", "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", "start": "pnpm with-env next start", diff --git a/apps/map/playwright.config.ts b/apps/map/playwright.config.ts index 35e4925d..41a74266 100644 --- a/apps/map/playwright.config.ts +++ b/apps/map/playwright.config.ts @@ -1,11 +1,14 @@ import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +// TODO: Better separation of auth and no auth tests const config: PlaywrightTestConfig = { - testDir: "./tests", + testDir: "./e2e", timeout: 120000, use: { baseURL: "http://localhost:3000", - headless: false, + headless: true, viewport: { width: 1280, height: 720 }, screenshot: "only-on-failure", trace: "retain-on-failure", @@ -18,6 +21,37 @@ const config: PlaywrightTestConfig = { reuseExistingServer: !process.env.CI, }, retries: 1, + projects: [ + { name: "setup", testMatch: /setup\/.+\.spec\.ts/ }, + { + name: "chromium with auth", + testMatch: /auth\/.+\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + // Use prepared auth state. + storageState: "./e2e/setup/.auth/user.json", + }, + // Only run this if we need to reauthenticate + // dependencies: ["setup"], + }, + // { + // name: "chromium no auth", + // testMatch: /.*-no-auth\.spec\.ts/, + // use: { + // ...devices["Desktop Chrome"], + // }, + // }, + // { + // name: "chromium with auth", + // testMatch: /.*-auth\.spec\.ts/, + // use: { + // ...devices["Desktop Chrome"], + // // Use prepared auth state. + // storageState: "./e2e/.auth/user.json", + // }, + // dependencies: ["setup"], + // }, + ], }; export default config; diff --git a/apps/map/playwright.staging.config.ts b/apps/map/playwright.staging.config.ts index 98a05565..ced65489 100644 --- a/apps/map/playwright.staging.config.ts +++ b/apps/map/playwright.staging.config.ts @@ -1,15 +1,13 @@ import type { PlaywrightTestConfig } from "@playwright/test"; -import baseConfig from "./playwright.config"; - const config: PlaywrightTestConfig = { - ...baseConfig, + testDir: "./e2e", + timeout: 120000, use: { - ...baseConfig.use, baseURL: "https://staging.map.f3nation.com", + headless: true, + viewport: { width: 1280, height: 720 }, }, - // Don't start a web server for staging tests - webServer: undefined, }; export default config; diff --git a/apps/map/src/app/_components/forms/dev-debug-component.tsx b/apps/map/src/app/_components/forms/dev-debug-component.tsx new file mode 100644 index 00000000..23f615cf --- /dev/null +++ b/apps/map/src/app/_components/forms/dev-debug-component.tsx @@ -0,0 +1,117 @@ +import { useFormContext } from "react-hook-form"; + +import { Button } from "@acme/ui/button"; + +interface DevLoadTestDataValues { + id: string; + eventName: string; + eventDayOfWeek: string; + submittedBy: string; + aoLogo: string; + aoName: string; + locationAddress: string; + locationAddress2: string; + locationCity: string; + locationState: string; + locationZip: string; + locationCountry: string; + eventTypeIds: number[]; + eventStartTime: string; + eventEndTime: string; + eventDescription: string; + locationDescription: string; + aoWebsite: string; +} +export const DevLoadTestData = <_T extends DevLoadTestDataValues>() => { + const form = useFormContext(); + return ( + + ); +}; + +interface FormDebugValues { + id: string; + originalEventId?: string; + newEventId?: string; + + originalLocationId?: string; + newLocationId?: string; + + originalAoId?: string; + newAoId?: string; + + originalRegionId?: string; + newRegionId?: string; +} +export const FormDebugData = <_T extends FormDebugValues>() => { + const form = useFormContext(); + const formId = form.watch("id"); + const formOriginalEventId = form.watch("originalEventId"); + const formNewEventId = form.watch("newEventId"); + + const formOriginalLocationId = form.watch("originalLocationId"); + const formNewLocationId = form.watch("newLocationId"); + + const formOriginalAoId = form.watch("originalAoId"); + const formNewAoId = form.watch("newAoId"); + + const formOriginalRegionId = form.watch("originalRegionId"); + const formNewRegionId = form.watch("newRegionId"); + return ( +
+

formId: {formId};

+

+ originalEventId: {formOriginalEventId}; +

+

+ newEventId: {formNewEventId}; +

+

+ originalLocationId: {formOriginalLocationId}; +

+

+ newLocationId: {formNewLocationId}; +

+

+ originalAoId: {formOriginalAoId}; +

+

newAoId: {formNewAoId};

+

+ originalRegionId: {formOriginalRegionId}; +

+

+ newRegionId: {formNewRegionId}; +

+
+ ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx new file mode 100644 index 00000000..36c7ad2a --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx @@ -0,0 +1,110 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { Input } from "@acme/ui/input"; + +import { scaleAndCropImage } from "~/utils/image/scale-and-crop-image"; +import { uploadLogo } from "~/utils/image/upload-logo"; +import { DebouncedImage } from "../../debounced-image"; + +interface AODetailsFormValues { + aoName?: string; + aoWebsite?: string | null; + aoLogo?: string | null; + originalRegionId: number; + id: string; + badImage: boolean; +} + +export const AODetailsForm = <_T extends AODetailsFormValues>() => { + const form = useFormContext(); + const formOriginalRegionId = form.watch("originalRegionId"); + const formId = form.watch("id"); + return ( + <> +

+ New AO Details: +

+ +
+
AO Name
+ +

+ {form.formState.errors.aoName?.message?.toString()} +

+
+ +
+
+ AO Website +
+ +

+ {form.formState.errors.aoWebsite?.message?.toString()} +

+
+ +
+
+
+ AO Logo +
+ { + return ( +
+ { + console.log("files", e.target.files); + const file = e.target.files?.[0]; + if (!file) return; + + const blob640 = await scaleAndCropImage(file, 640, 640); + if (!blob640) return; + const url640 = await uploadLogo({ + file: blob640, + regionId: formOriginalRegionId ?? 0, + requestId: formId, + }); + onChange(url640); + const blob64 = await scaleAndCropImage(file, 64, 64); + if (blob64) { + void uploadLogo({ + file: blob64, + regionId: formOriginalRegionId ?? 0, + requestId: formId ?? "", + size: 64, + }); + } + }} + disabled={ + typeof formOriginalRegionId !== "number" || + formOriginalRegionId <= -1 + } + className="flex-1" + /> + {value && ( +
+ form.setValue("badImage", true)} + onImageSuccess={() => form.setValue("badImage", false)} + width={96} + height={96} + /> +
+ )} +
+ ); + }} + /> +
+
+ + ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx new file mode 100644 index 00000000..92480bc7 --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx @@ -0,0 +1,108 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { orpc, useQuery } from "~/orpc/react"; +import { VirtualizedCombobox } from "../../virtualized-combobox"; + +interface AOSelectorProps { + label?: string; + fieldName?: "originalAoId" | "newAoId"; + regionFieldName?: "originalRegionId" | "newRegionId"; +} + +interface AOSelectorFormValues { + originalRegionId?: number | null; + newRegionId?: number | null; + originalAoId?: number | null; + newAoId?: number | null; + originalEventId?: number | null; + newEventId?: number | null; +} + +/** + * AO selector component - handles AO selection based on selected region + * Single Responsibility: Display and manage AO selection + * Depends on region being selected first + * Side Effects: Clears dependent Event field when AO changes + */ +export function AOSelector<_T extends AOSelectorFormValues>({ + label = "From AO (optional):", + fieldName = "newAoId", + regionFieldName = "newRegionId", +}: AOSelectorProps) { + const form = useFormContext(); + const regionId = form.watch(regionFieldName); + + const { data: results } = useQuery( + orpc.map.location.getAOsInRegion.queryOptions({ + input: { regionId: regionId ?? -1 }, + enabled: regionId != null, + }), + ); + + const aoOptions = + results?.aos + ?.map((ao) => ({ + label: ao.name, + value: ao.id.toString(), + labelComponent: ( +
+
{ao.name}
+ {Array.isArray(ao.workouts) && ao.workouts.length > 0 && ( +
+ {ao.workouts + .filter((w): w is string => typeof w === "string") + .join(", ")} +
+ )} +
+ ), + })) + .sort((a, b) => a.label.localeCompare(b.label)) ?? []; + + // Determine which event field to clear based on the AO field + const getEventField = () => { + if (fieldName === "originalAoId") { + return "originalEventId" as const; + } + return "newEventId" as const; + }; + + const eventField = getEventField(); + + return ( +
+
{label}
+ ( + <> + { + const ao = results?.aos?.find( + (ao) => ao.id.toString() === item, + ); + const newValue = ao?.id ?? null; + + // Set the AO + field.onChange(newValue); + + // Clear dependent event field when AO changes + if (field.value !== newValue) { + form.setValue(eventField, null); + } + }} + searchPlaceholder="Select AO" + /> +

+ {fieldState.error?.message?.toString()} +

+ + )} + /> +
+ ); +} diff --git a/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx new file mode 100644 index 00000000..ff311da5 --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx @@ -0,0 +1,38 @@ +import { useFormContext } from "react-hook-form"; + +import { Input } from "@acme/ui/input"; + +import { useAuth } from "~/utils/hooks/use-auth"; + +interface ContactDetailsFormValues { + submittedBy: string | null; +} +export const ContactDetailsForm = <_T extends ContactDetailsFormValues>() => { + const form = useFormContext(); + const { session } = useAuth(); + + return ( + <> +

+ Contact Information: +

+
+
+ Your Email +
+ +

+ We will send you a confirmation email when your update request is + approved. +

+

+ {form.formState.errors.submittedBy?.message?.toString()} +

+
+ + ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx b/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx new file mode 100644 index 00000000..9f0cab1c --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx @@ -0,0 +1,61 @@ +import type { FieldPath, FieldValues } from "react-hook-form"; +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, FormItem, FormLabel, FormMessage } from "@acme/ui/form"; +import { Input } from "@acme/ui/input"; + +interface TimeInputProps { + name: FieldPath; + id?: string; + label: string; +} + +export const ControlledTimeInput = ({ + name, + id, + label, +}: TimeInputProps) => { + const form = useFormContext(); + + return ( + ( + + {label} + + { + const timeValue = e.target.value; + // Convert "05:30" -> "0530" before storing in form + if (timeValue && timeValue.includes(":")) { + const [h, m] = timeValue.split(":"); + field.onChange( + (h?.padStart(2, "0") ?? "") + (m?.padStart(2, "0") ?? ""), + ); + } else { + field.onChange(timeValue); + } + }} + className="w-full" + /> + + + + )} + /> + ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx b/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx new file mode 100644 index 00000000..79e9c462 --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx @@ -0,0 +1,77 @@ +import { useFormContext } from "react-hook-form"; + +import type { DeleteAOType } from "@acme/validators/request-schemas"; + +import { orpc, useQuery } from "~/orpc/react"; + +export const DeleteAoForm = <_T extends DeleteAOType>() => { + const form = useFormContext(); + const originalAoId = form.watch("originalAoId"); + const { data: result } = useQuery( + orpc.org.byId.queryOptions({ + input: { id: originalAoId, orgType: "ao" }, + enabled: !!originalAoId, + }), + ); + return ( +
+
+ {/* AO Details Section */} +
+
+ + + + + +
+
+
+ {result?.name ? ( + result.name + ) : ( + AO name loading... + )} +
+
+ AO ID: {originalAoId} +
+
+
+ {/* Warning Section */} +
+

+ Attention Required +

+
+

+ You are about to request deletion of an AO. +
+ + This will delete{" "} + + {result?.name ? result.name : "this AO"} + + , all its workouts, and possibly the location if no other events + exist there. + +
+ This action cannot be undone. Please confirm you want to proceed + with this deletion request. +

+
+
+
+
+ ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx b/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx new file mode 100644 index 00000000..58987b55 --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx @@ -0,0 +1,70 @@ +import { useFormContext } from "react-hook-form"; + +import type { DeleteEventType } from "@acme/validators/request-schemas"; + +import { orpc, useQuery } from "~/orpc/react"; + +export const DeleteEventForm = <_T extends DeleteEventType>() => { + const form = useFormContext(); + const originalEventId = form.watch("originalEventId"); + const { data: event } = useQuery( + orpc.event.byId.queryOptions({ + input: { id: originalEventId }, + enabled: !!originalEventId, + }), + ); + return ( +
+
+ {/* Event Details Section */} +
+
+ + + + + +
+
+
+ {event?.name ? ( + event.name + ) : ( + + Event name loading... + + )} +
+
+ Event ID: {originalEventId} +
+
+
+ {/* Warning Section */} +
+

+ Attention Required +

+
+

+ You are about to request deletion of an event. +
+ This action cannot be undone. Please confirm you want to proceed + with this deletion request. +

+
+
+
+
+ ); +}; diff --git a/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx new file mode 100644 index 00000000..7d37400f --- /dev/null +++ b/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx @@ -0,0 +1,200 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import type { EventFieldsType } from "@acme/validators/request-schemas"; +import { DayOfWeek } from "@acme/shared/app/enums"; +import { Case } from "@acme/shared/common/enums"; +import { convertCase } from "@acme/shared/common/functions"; +import { Input } from "@acme/ui/input"; +import { MultiSelect } from "@acme/ui/multi-select"; +import { ControlledSelect } from "@acme/ui/select"; +import { Textarea } from "@acme/ui/textarea"; + +import { orpc, useQuery } from "~/orpc/react"; +import { ControlledTimeInput } from "./controlled-time-input"; + +interface EventDetailsFormValues { + originalRegionId: number; + eventName?: string; + eventDayOfWeek?: DayOfWeek | null; + eventTypeIds?: number[]; + eventStartTime?: string; + eventEndTime?: string; + eventDescription?: string; + currentValues?: Partial; +} + +export const EventDetailsForm = <_T extends EventDetailsFormValues>() => { + // I'd like for this to be generic, but the types don't seem to be working as expected. + const form = useFormContext(); + const formRegionId = form.watch("originalRegionId"); + const currentValues = form.watch("currentValues"); + const formEventDayOfWeek = form.watch("eventDayOfWeek"); + const formEventStartTime = form.watch("eventStartTime"); + const formEventEndTime = form.watch("eventEndTime"); + const formEventTypeIds = form.watch("eventTypeIds"); + const formEventDescription = form.watch("eventDescription"); + const formEventName = form.watch("eventName"); + + // Get event types for the region + const { data: eventTypes } = useQuery( + orpc.eventType.all.queryOptions({ + input: { orgIds: formRegionId ? [formRegionId] : [] }, + enabled: formRegionId != null, + }), + ); + + return ( + <> +

+ Event Details: +

+
+
+
+ Workout Name +
+ + {currentValues?.eventName && + currentValues.eventName !== formEventName && ( +

+ {currentValues.eventName} +

+ )} +

+ {form.formState.errors.eventName?.message} +

+
+ +
+
+ Day of Week +
+ ({ + value: day, + label: convertCase({ + str: day, + fromCase: Case.LowerCase, + toCase: Case.TitleCase, + }), + }))} + placeholder="Select a day of the week" + /> + {currentValues?.eventDayOfWeek && + currentValues.eventDayOfWeek !== formEventDayOfWeek && ( +

+ {currentValues.eventDayOfWeek} +

+ )} +

+ {form.formState.errors.eventDayOfWeek?.message} +

+
+ +
+ + {currentValues?.eventStartTime && + currentValues.eventStartTime !== formEventStartTime && ( +

+ {formatTime(currentValues.eventStartTime)} +

+ )} +
+
+ + {currentValues?.eventEndTime && + currentValues.eventEndTime !== formEventEndTime && ( +

+ {formatTime(currentValues.eventEndTime)} +

+ )} +
+
+
+ Event Types +
+ { + return ( +
+ ({ + label: type.name, + value: type.id.toString(), + })) ?? [] + } + onValueChange={(values) => + field.onChange(values.map(Number)) + } + placeholder="Select event types" + /> + {fieldState.error && ( +

+ You must select at least one event type +

+ )} +
+ ); + }} + /> + {currentValues?.eventTypeIds && + JSON.stringify(currentValues.eventTypeIds) !== + JSON.stringify(formEventTypeIds) && ( +

+ {currentValues.eventTypeIds + .map( + (id) => + eventTypes?.eventTypes.find((type) => type.id === id) + ?.name, + ) + .join(", ")} +

+ )} +
+ +
+
+ Event Description +
+