From 0b5dd08378357078f60077fce79d0d8eed45b6a5 Mon Sep 17 00:00:00 2001 From: Declan Nishiyama Date: Sat, 3 Jan 2026 13:04:58 -0500 Subject: [PATCH] Create new edit flow --- .env.example | 21 + .vscode/launch.json | 19 + README.md | 9 + apps/map/e2e/auth/admin-portal.spec.ts | 357 ++ apps/map/e2e/helpers.ts | 264 ++ apps/map/e2e/no-auth/edit.spec.ts | 475 +++ .../no-auth/initial-load.spec.ts} | 2 +- .../no-auth}/manage-event-workflow.spec.ts | 41 +- apps/map/e2e/no-auth/nearby-locations.spec.ts | 40 + apps/map/e2e/no-auth/update-actions.spec.ts | 314 ++ apps/map/e2e/setup/auth.spec.ts | 32 + apps/map/e2e/tests/image.png | Bin 0 -> 17759 bytes apps/map/package.json | 1 + apps/map/playwright.config.ts | 38 +- apps/map/playwright.staging.config.ts | 10 +- .../_components/forms/dev-debug-component.tsx | 117 + .../forms/form-inputs/ao-details-form.tsx | 110 + .../forms/form-inputs/ao-selector.tsx | 108 + .../form-inputs/contact-details-form.tsx | 38 + .../form-inputs/controlled-time-input.tsx | 61 + .../forms/form-inputs/delete-ao-form.tsx | 77 + .../forms/form-inputs/delete-event-form.tsx | 70 + .../forms/form-inputs/event-details-form.tsx | 200 + .../forms/form-inputs/event-selector.tsx | 87 + .../existing-location-picker-form.tsx | 120 + .../forms/form-inputs/in-region-form.tsx | 66 + .../form-inputs/location-details-form.tsx | 132 + .../form-inputs/region-and-ao-selector.tsx | 60 + .../form-inputs/region-and-event-selector.tsx | 39 + .../form-inputs/region-ao-event-selector.tsx | 62 + .../forms/form-inputs/region-selector.tsx | 104 + .../forms/form-inputs/selection-form.tsx | 200 + .../_components/forms/location-event-form.tsx | 573 --- .../app/_components/forms/submit-section.tsx | 348 ++ .../map/desktop-location-panel-content.tsx | 72 +- .../src/app/_components/map/group-marker.tsx | 6 + .../map/initial-location-provider.tsx | 71 +- .../_components/map/layout-edit-button.tsx | 2 +- .../_components/map/location-edit-buttons.tsx | 306 ++ .../src/app/_components/map/update-pane.tsx | 184 +- .../marker-clusters/clustered-markers.tsx | 3 +- .../modal/admin-delete-request-modal.tsx | 208 - .../modal/admin-requests-modal.tsx | 216 - .../modal/admin-workouts-modal.tsx | 4 +- .../src/app/_components/modal/base-modal.tsx | 40 + .../app/_components/modal/country-select.tsx | 4 +- .../app/_components/modal/delete-modal.tsx | 58 - .../app/_components/modal/loading-modal.tsx | 31 + .../app/_components/modal/modal-switcher.tsx | 90 +- .../modal/update-location-modal.tsx | 266 -- ...create-ao-and-location-and-event-modal.tsx | 61 + .../modal/update/create-event-modal.tsx | 44 + .../modal/update/delete-ao-modal.tsx | 46 + .../modal/update/delete-event-modal.tsx | 46 + .../update/edit-ao-and-location-modal.tsx | 54 + .../modal/update/edit-event-modal.tsx | 45 + .../move-ao-to-different-location-modal.tsx | 53 + .../move-ao-to-different-region-modal.tsx | 56 + .../update/move-ao-to-new-location-modal.tsx | 54 + .../move-event-to-different-ao-modal.tsx | 59 + .../update/move-event-to-new-ao-modal.tsx | 62 + .../move-event-to-new-location-modal.tsx | 55 + .../modal/utils/handle-submission-error.ts | 60 + .../modal/workout-details-modal.tsx | 41 +- .../app/_components/virtualized-combobox.tsx | 9 +- .../workout/workout-details-content.tsx | 323 +- .../src/app/admin/requests/requests-table.tsx | 292 +- apps/map/src/app/auth/layout.tsx | 2 +- apps/map/src/trpc/util.ts | 3 + apps/map/src/trpc/vanilla.ts | 3 + apps/map/src/utils/forms.ts | 19 - apps/map/src/utils/open-request-modal.ts | 480 +++ .../src/utils/secondary-effects-provider.tsx | 3 +- apps/map/src/utils/store/modal.ts | 257 +- apps/map/src/utils/types.ts | 4 + apps/map/tests/helpers.ts | 66 - apps/map/vitest.config.ts | 2 +- packages/api/package.json | 5 +- packages/api/src/__test__/fixtures/index.ts | 3 + .../src/__test__/fixtures/update-request.ts | 248 ++ packages/api/src/__test__/mock/context.ts | 26 + packages/api/src/__test__/mock/db.ts | 112 + packages/api/src/__test__/mock/index.ts | 5 + packages/api/src/__test__/mock/session.ts | 38 + .../api/src/__test__/update-request.test.ts | 730 ++++ packages/api/src/check-has-role-on-org.ts | 2 +- packages/api/src/lib/ao-handlers.ts | 115 + .../api/src/lib/check-update-permissions.ts | 81 + packages/api/src/lib/event-handlers.ts | 204 + packages/api/src/lib/location-handlers.ts | 132 + packages/api/src/lib/types.ts | 22 + .../api/src/lib/update-request-handlers.ts | 395 ++ packages/api/src/router/event-type.ts | 5 +- packages/api/src/router/map/location.ts | 52 + packages/api/src/router/request.ts | 717 +--- packages/api/vitest.config.ts | 12 + packages/db/drizzle/0010_luxuriant_toxin.sql | 9 + packages/db/drizzle/meta/0010_snapshot.json | 3529 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/drizzle/schema.ts | 2 +- packages/db/src/migrate.ts | 5 + packages/env/package.json | 1 + packages/env/src/index.ts | 29 +- packages/shared/src/app/constants.ts | 4 + packages/shared/src/app/enums.ts | 15 +- packages/shared/src/app/functions.ts | 43 +- packages/shared/src/common/constants.ts | 67 +- packages/shared/src/common/enums.ts | 1 + packages/ui/src/form.tsx | 13 +- packages/validators/src/index.ts | 81 +- packages/validators/src/request-schemas.ts | 251 ++ pnpm-lock.yaml | 228 +- 112 files changed, 12405 insertions(+), 2739 deletions(-) create mode 100644 .env.example create mode 100644 apps/map/e2e/auth/admin-portal.spec.ts create mode 100644 apps/map/e2e/helpers.ts create mode 100644 apps/map/e2e/no-auth/edit.spec.ts rename apps/map/{tests/inital-load.spec.ts => e2e/no-auth/initial-load.spec.ts} (97%) rename apps/map/{tests => e2e/no-auth}/manage-event-workflow.spec.ts (77%) create mode 100644 apps/map/e2e/no-auth/nearby-locations.spec.ts create mode 100644 apps/map/e2e/no-auth/update-actions.spec.ts create mode 100644 apps/map/e2e/setup/auth.spec.ts create mode 100644 apps/map/e2e/tests/image.png create mode 100644 apps/map/src/app/_components/forms/dev-debug-component.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/event-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/existing-location-picker-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/in-region-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/location-details-form.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/region-and-ao-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/region-and-event-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/region-ao-event-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/region-selector.tsx create mode 100644 apps/map/src/app/_components/forms/form-inputs/selection-form.tsx delete mode 100644 apps/map/src/app/_components/forms/location-event-form.tsx create mode 100644 apps/map/src/app/_components/forms/submit-section.tsx create mode 100644 apps/map/src/app/_components/map/location-edit-buttons.tsx delete mode 100644 apps/map/src/app/_components/modal/admin-delete-request-modal.tsx delete mode 100644 apps/map/src/app/_components/modal/admin-requests-modal.tsx create mode 100644 apps/map/src/app/_components/modal/base-modal.tsx delete mode 100644 apps/map/src/app/_components/modal/delete-modal.tsx create mode 100644 apps/map/src/app/_components/modal/loading-modal.tsx delete mode 100644 apps/map/src/app/_components/modal/update-location-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/create-event-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/delete-ao-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/delete-event-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/edit-event-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx create mode 100644 apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx create mode 100644 apps/map/src/app/_components/modal/utils/handle-submission-error.ts create mode 100644 apps/map/src/trpc/util.ts create mode 100644 apps/map/src/trpc/vanilla.ts delete mode 100644 apps/map/src/utils/forms.ts create mode 100644 apps/map/src/utils/open-request-modal.ts delete mode 100644 apps/map/tests/helpers.ts create mode 100644 packages/api/src/__test__/fixtures/index.ts create mode 100644 packages/api/src/__test__/fixtures/update-request.ts create mode 100644 packages/api/src/__test__/mock/context.ts create mode 100644 packages/api/src/__test__/mock/db.ts create mode 100644 packages/api/src/__test__/mock/index.ts create mode 100644 packages/api/src/__test__/mock/session.ts create mode 100644 packages/api/src/__test__/update-request.test.ts create mode 100644 packages/api/src/lib/ao-handlers.ts create mode 100644 packages/api/src/lib/check-update-permissions.ts create mode 100644 packages/api/src/lib/event-handlers.ts create mode 100644 packages/api/src/lib/location-handlers.ts create mode 100644 packages/api/src/lib/types.ts create mode 100644 packages/api/src/lib/update-request-handlers.ts create mode 100644 packages/api/vitest.config.ts create mode 100644 packages/db/drizzle/0010_luxuriant_toxin.sql create mode 100644 packages/db/drizzle/meta/0010_snapshot.json create mode 100644 packages/validators/src/request-schemas.ts 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 0000000000000000000000000000000000000000..07839be737c6321ebdde62341908a7b3cd5492e6 GIT binary patch literal 17759 zcma%?V{;{4!-eDAv8{=18|TC}C$_DLCbsRIm=hZlJDJ$FZM}K^!P`~M58YM0xz@UN zgpz_J5-@wU~2jILvR-r zNindRDZ-QQ8z>7=c~LO1x;TV)BWN%%;Jq|ZRLv9o;wP++syfbBDI-yp3eB%8%3tKN zs8D)k4C2X*^+G^2aq;+KGA73WMA1}Rvv4w55pkMds$;g}bkOk0Q$9!QPqQVI@|Uvu zd|P>2q*k|yogQ0)Pr1HekT8h2EQWsr!2e$tD805^zsi!MMwY}Zrm}C84H`Nf>IM2l zLi=0c@Su#r{Jm+!)e_5~l$Ajupg2gSG%i2Dy325|Bpb{DaLrxKlJjb4+0pdi6N z*@tjzboF3A#6sBg3xIh&)-W&Z_&x8_=ti_~UR?9wjIL9H@wjB|3#G%;ic*p`+M{9a%@-Ysj$1f;)yB z9`Sx$z^AMU5(S3DH}q=s8APO_en1ay_fD9Ff`bPY*aqN^h7q@;B2ik)BqP97^&$=| z_IUv-Bc^))wLl?;)_b?4@?+C zB}XbPWu7rLq^5r}Hx$9xjYt(;X<8=qXQt*$bh)o0m7tSA0fg(99{t9f0-lsdv|g!@ zpxRO416)&Sgs3mHcoPdiAW-D~WL68apl?_i7~6jX1L09IfcuZP@R%5J*L&afc3%QC zZZgL(;;YRLSZ7RfQd00br@HIg`@pm`93Nkwcxngz{io?T2x#~p`66Jq2jj2~4i3++ zFAX3yRAIl#}aW@vnJK|>j~+!VgP{C;k}h! zrzaW?lipuS?@p%&zRS&szKgXM$2eTN*jV<`)e1SPH&N`OyWZ&~oR_c5NYzjGUBp~p(KxhW`zLPw8 znfK#?A_c{>r47RkmXwr~$Lk#`@DX-rzFajtJp8ZVs++Ni@!+K?*=@eAq0A?Z?Qk?d z-?S+V<`#X8>`Qc78uh#75}jHZs`X-31f6;%$iAz+Os^RJ-O2+)?$Ga_Rfgg}FZobo z_qeH=SM_EKn&$PHegdEa+Cd1v$Q`c`DY3;gl*yI<7aHV+q|!g3;II&C_DF3ns*?6s zV{c@?(?N3Ph$tj{4v{-a5=xlLu(1?+QpMcPOCj3Lj8qI?2udaHwF`}v%mT|&%WQg0 zCaxnPc0R??glZ%evG_GfsDNN0iw4CN>P##Qg{UtS ziJ;D8*gYGCxAdIn(3ELsG|N)PNr<+a{QLJBO{ztdUv@gjMm}q|I{sMoI1ZS~;onQ( zYqB~TE7Z&TlA=mHJ%1FBrZb{-r0PRczA^%)MTc>4= z3(@U-nbm=9vRjP)V#!tYJs(o52muA5pQPvO_sW)Un9b53YCNRn+o4os^DGIwNjm zmBkAJHUm$Ry$|#29028vD69Fq&W1NtYV1u_>lzvbX9Qo>i|dVDBAVT@^m=z}%Z|zK z8jNEzvUrZ1m5FKqxK_@>0ZvX%AIKy^J`|N~>Zv^!1HglV;7*QESl>A~!>X+qiRrQ(P%SH=TY?hCDp)u#S4KxjT& z#_k1jKy$BA&%#%@1{s1miiLKlimz-=fmUQ>zqlS8%5b39U7hm*9dI*Jt^m^Ov{3>6 zjO2rTrh*t(3pXSRQ}jfIQn}t#8k1Hf`I9L+AOK9%_P_0ofI>2XuFgb6_!}pua7tsF z-L5hk;6_&z0k{1&UqoRiZm@oPH2qS|>=w7<#i{x%Ey;heioS)#cw^w{by~yTHwXG< z8rAsYuqgELs}^_;(Il1kKj^i=8`uC&z-JDc2tNgKA)#Ek&9m!`2?s|e3gC-9MU)4k z1>RV_%ggIaVqR%vx}&CIwEmUGd3LwN*RKiw1igtvBJUuGoe7EhcuhD<^ zn3eE*qzGnPd8<;*cVW~3@w7i3$NE9S{Ae+_RJ-cOyN;*{*adXz=i8rr)c z@#ireYdhPNxJOT}EtdPEX+|5W@*z8Csl@wh@#G&Vc1Sn1pw&qtC7E1!Sa1LNs^RlL zrh$n&TvkPZanWrG9Tn|)1{oH`0>?h|!ECWCkMlEXbG(W7T61-HWP6sWr4#ff2liwp z_i)Emw=ALt>p|8O(jYiUvp~%va(qHA#SAxcj@}YouAuiEf%8W33`mK9{<&HQndi?% zWFdtp(->*Vl2HH0jm1Z*hgOl^IV@IjJ0g}y9IT=qqiqoG6#x8~lkkR^7(0?^&BO4H zB+Dt7aEZ|6m;rr3sV|XI#&}>Pp~ys#9<={+01lLUYEpuvJ%)~0j2*29jGGq4x@%;c zs~*u!7w;}2@qb5y8_=S9n8ipM;A?2)UW~(Zily*;Y-6%qt)g}I`X!8MolI-w>q|v& zHN#mu6GSeW`t{E4ezh7?xm2_I*-4}&X>`r>F|Vp=+Im8Z;s*LSnpl5duI0e>|7~90glk8 z!Yh-HcDMA0Nsm^am-;zuvCYviWRlQNNZ9z+^RuyYe#v`?^8AzmAW9NR_bo&bNk|&pj_1nYf&2){$Qd3S=p`_y)UF07}IBL zoRm=8$|e6j4U+{WoR}kSMEe2RkINlf#@R5p z20r%liFkdW`uh8~jm&yjbfGs^Ub+18y57hoK2exm&*=0anhO~=&G zGK{2a8liQiV?QZ0{I7R-T)i%NTfyv||7m~3AjXu9a5P9T_I+^()+iA~TlIa9F|kiW zv4a=kBSps!-Flg(NtG{5BQvkTP)Ag81~}p1N);%wN85uV*X8k6WPr%s3@1Y z6gLGtuYyp3a`}_^IL=ST$TKW7hxdq4ALDX}gI&oy)}8Pi#&3@oxPsQ0_DO%FW9rd5 zVh_wStNQw@uNYzT9?u`pCAx1yr63i*Czg%BVIGBegO>gn#4(pB4mS~S4~*9*;B0ma zdHS!znu2+s>~J4kLJuo-Npq0k9T$#a&Pgy9)f34CvB@vr9$|N~8bc8Sg_Z2HGf{^- zl1H} z%X^n0!sr~3KS5L?643#^WO3{bTc#3Me!0sAClrM!6xW=}k90whg><1y>C1Ax2+l8E z7=_b%v9`JpZ(^Tt9{ecLOi}owal~E7%zS z9sTbi>J7^*}`;EG+l-&MvUo-8JIJxoy5|h;N2= zUWpq^=(}Ddp&ozSV%RIjVSTFR2C1O5zo|c*5c{eY%96bLFB(A!dFGZ^;lbRT&+EOF ztR_Q_7T6IsAmB|}D~0BBx6x9zmM?^8%D|K@97h^tFkC)YI=DsberlqHz0&D*XS@=5 z0CH_q8fcu?Ws6`JYP%aFoIBEWe1n$PZ|_j&AE1Knzk0b0D-2~00`^!_l!!5rNdacH8b&1CFW@$m`mtq3WnpbR!)t@pRr zGdvom4E$+7#@MA!&mD+tFoM6Q(9qCc6;6frqTXWwJ%s$W^Ikn>hnIRY9{2m>{$kWB zxm|4IQoEkdkALm*1h^wbEQ0SI)vHY`xzX~SZvup?w=81)VmE_Fc~LI~Xl)S*RH4Da z*O&WTkmBtum_PsX(6HE$_BLB?a-r^iJ*^CrD_#Ws7LPU;EMKB$HW>-uVTm6#3bI-i z{Nx$(yB3;NPg1Ek*z&kiBsI3~Cyaktb1nQ=1=Qj~5p``{5y%&EXcwQkq4PIzxbl z2I>Z3K%1 zMNUr6jxFS}Ks3R)taiM>({5WNTgWYwX>shz+~i=#_2Ga5Tcgq*%aPQJ(q|5Zh%cDG z5n(7{hMZOOrTU)55(<+ce0UUO=3Aq;Y`WHLH%5X#CMp|LQ)$1^UZ$e%Vg_D!d-d{U zaK7A~Gjz_o(fXVf*LHHiP#W%cuEu{(j3sq@#NdOIb-Y-pAj$Du;z5cP9ZQ4D0 zGBf|dG0TXk%XBYoD4h!LZQw3#mDprqi_@$wu?rj+8Zg{wcYDs1yFn}ZHzW{FI>Udn zlS*$7oW$n@X5rX;D{+(a4ik0A=#`U-s>kc0GG&gs$`M9B^N`V{k5>f0Pp`J)f25hs zXq{coltfhfu(FN9AJYqQt(9%XjNljpsD;*K#kM7ZP}i|P>X!F6AkhDb@d z`p7TXECLnLVH@#DkqLRi<4WbkpAT3DHsX{&v^9!{R9^VjMXh>^hAJ~AsI=xMAB%qjGKJUd z-(ek>C1WvpR_)!N93Joc|2P6Q4^mNEL`Z&)F(RtBI__v%#TWAmG9 zJ1vwG3Oh6Ns5s>8FCcic6Gqa_X{Dy*BYh055I5bskXV^B7*A91cA!>3+} z)L)bv<|dCw)y?YtVL#R!jS@sJ@!)%awNq0|OsyOdJ{98;A^+_r3{2&=Bm)qz1WkLT>pZlvVMNIN01o0AJgbF3=c9ST}Tuoa1 z5TzDHz($8h)P&&*Z$aRPN}L$=#R3sOriGM7DsMca1ifa>K+c*6AwIN^%Nv~XwDz88 z!{MYQ`hMu|WJ8$OM8cD|pvTg5#Kne6o242W-5Lg!)JK(EmjL=SRr}ROIMbV}o?k3R z?@*-1WBE86PxYq9j!@mHye@u@5-11tO%da1%pP88vnd7h&t*$L+I;IFIubFr)X=~B zw`3h0ol;ia&i-n1*sp6*5T?@T>!CF;1l1U&xlj+Rq{mY{o;qaK#z2aSYBd@b)MshK z^wno&VtrU{c6zl+dtU+)=uejqM$^*VbHF3r7L#wgF!O5BO}^GU@eptr0ubS`8G?7d z2*6QbqiB0N%1E^0ZLLeb`F7~P$X%280AK0o!h_=Pe zz~xV2o$5disl^`HUzy=jN+c*3!U38Fmof424WlFqZZautvT%4z3}hWmMOGhZHp;G- z>%4tG$=IKB_}s+?Wm3+GO=bj_;^zxl(GpsO?PHR*t^ZYRA`Xhf6x$ZAFS+f!lkAu| zcV={n-TIcR6z9XjW77pc?(<}G{^IKXxa7Yz+Lx37={%ek<-NxWTMiz&7n=-q2Zio8 za(Q{TZg6AcU_|BcekP_VFjnyWi@OfOVCO<=ZL-AFSi`*J7e;}yz@F-h(V)h zy3-0;Sb4RJ&`u`zt1i~)FfRC?oLJ<}?4vd!5Ty3v?=Ox0YmDPKU)QTE-wOEDYIf!= z@wtVAfIukG_V1-dRs5d;{OG;4c3Yey;01wwAf-iC-Hl>?5o=ZB@Avj-_Xb;Tk{IVM zYJVx>Tn~u4zC935T22E)x+Y_-^9X`LV$eq{&8G%*{Wv4sV^|M@MS-W}>YXZQCwCIo z@KEqHnElCS?_Uoq9(Ox-5W_Sr941eJ-zBoh<(_%!)?Ihw^sgOzc<@wrGZxUhEKCE< zw^W+zg>NR4hONx>m`v_Ie7@F#q9by5?$t!xKIc<_0C3uo0S%kQZME6pAejQ|IcbsR z9oA6~TFp#h?0;lH6Tp~w%@Liwjsl5kblaoN59f7{_K7ZfgDg+>ywbcuCX%#( zAv!h%OQ5(jMH&h`{Ma%P9TQXRIIS#}j>*}$0|7TYMm6;;6Y(^$r~O~Ev2r_JJ3TJ? zLtkmceO&Ix@iuGyKR;WTZW~xdAy4P$)}2O}v|j&JkUM{w)X<#$=bz9k&ai%aw5y21 zD19#L%7rP76ABugMh2m?fmRO2)0|!hqh+6ng=C)+M=XK2^GqnYGb!dgZ;)ros zohh^-1a~46xKY~3SDS69eSIR(DQ8OD2#(?Od73W0vFJ2_^bxqimlL;JxnKEc?}Pe! zL~sfs%tSJmHR)}JJBJ(}^3q{56jtJXWCrBb8&q0x#zEqx3Yogrg26leVF`PU`GD~Z z?ud*%Awo$ZQQty&nIxfs-70KHLL*7^H$uj31|%mDd*eW=Jt)|5n6e?bNym%=HH^aK zPfSVV0fVK_igzQEho-;7Yp^+a=ZJ*}zg7*Y={zd`=vTKtuW7?Nw%uZ}#JwAcM`6i7 z{?@6ut6)v)43f(fz`G!JfURatj=erEjFX9QD4$8xLtI~;B!0%sDB!u)fuu{sdPTE% zpb>O0O9T!Q5sQ1A_Ju_8xE(fHsvA#7{4BL6QmdfALQXGC*xDj-==w-?IpC`TdzUS_nhbMG^q+SPwUzR0;{J$#yG zMyH+&q{JuReRa#`ahFqO+y_xT`{46dN}ePbF2f|AVP%br*JqBJwp>|0Cp|4lj9y&|C~Er#$#cyQWIrDt;1e;K^N5lS55#kY~dmdkosX+Vqb z*VMGY8$)wEJa|Vp2baRRNR&o(kz^QdWKJguAfkIV+4J}oS=_lI`E#Ih>D!gB#(Zyq z)NjI%8ZiT`Cqc+p_4bGMjm!Rl9+63_xAi6aZ~F7&KO`h9r-*vv(w3GMnyB0}{F6!lC`0*N4x>?WYSnsh-8U2TtebdL7SR*j8sujzmjQxwlN#T9m9Yg@F1(GKObojr$NT-2Vz{~<4Pu7NS ztgk}F=?;4#8MHfbZ5Cw)rs{qd1_%qiTSDaee)29&hw-$+$+8=xNxhAOE+ve?Z~mKseXbYvW=DtDlLxnuMY(OIW6hG)CUIx z2U%vCDpQ?2O~2zuIMeKHMX3f*CE_5LZu!s4fbfk5fBkdTqrMv$!oLzAw710LLzBHB zBq>%K%Ux_*6^b^;kSJUZ!yk_q8wj!;j!Bup$41RLxMNK5vu5M@{$HPD$kR(pic2|( zvZE=?2Du9;9m7Ndu2RR!xwk|IXMWE+Vc6hWt1LSlfO0DFa`W;Dsc*N(-BWL+-fZWO zrl}Vf*ED|q~eek79Fk3#C&I)pPh1$)o!FQo7AH!Wp89@ z>kzPXD(6Nl?y^ks(5`2_ZG+7Da6C)#{#?*^_46IR%Lgx^g$xZB_djXJ;dHRsLVEzD z-b#ViOgWv)4-bcwI#U4e3;txu@2M7)75n?4AdV*LYj}QVnUXQvN*eKPsi*Lu0?yhq;&nEvKa0?p(oN*vcLtLVZ~3 zI6SF{x2LPU{wLwTE9||}-T5vYx!;}@>fEWEBJD;5PfuczW0jJN0h$;pw??LGjqB17vh{+Fkwm6I(6_#!ml*tRQlFGGaeXg zqNZ|SaTv9Mp%Hb*@B2yD*YniL$%WQHa2!~^SGWYiayU+_u-Bvof)$m?8a*`K=T0-^ zPRA1vAk3aL(Jl%zVRxDknkl}|m^f@k3n!_LX`MOnu|Sp4C}t#OoIudP2W?vLtFzxk z`)iL6oiOHm2EXSjtHGetNCPhowd}m%6JrR=jM8z>cEtghkx#A3M3-S}EMzxc(aQJo$Gb#_T9gt9{1yr+R(%?(M4Er6hC{uzgdmQJvYp_dQwv zvvV}>E|+OaRf-oWx2{s%fw7C>csDdzgsx~+;J735{AOVb4U&V#udJmJ&+Fvzu_bUbwaNgz?hcL!C`%VcpJ`*1 z5!9*=7UstzRtF0GG>zf+u#eK&JK2#itc#jlmmJX<#@7BEtZ3YI;TG*0Zz+Z;Ex&VE zEZ4-T$(J(Xbt6s0rZM76gCyAO8b!#DMnxqjE5IESxW#>)RYvW?RXv zg}7&&l?|m8=8~nS#UF{Cf}ZXeR8!|{cCu+~%Xj4HequM7av&+Q)8_G&8-B;euVp3v zXa<9wJt4Q;>_&f~V56dSvkvZV=ELD9U_O;jntf%P+H%bM?)i9+*ZH;imM-8#w!3%F zNHSC#ZcKP|LU(&OP2H5W*;c6CxlhOST?=2v30>bFbBZF2P(bNs$Z?6DA{g2HSv^9Q zEX3;_R~mv5;+;T^op){>0yT)T!p(#abuo3af zw<*r**F&VfI|{7H__Ivv)q9K8Qsr1&BuiK$1^k$bCF3~E5pY7MG2HP|Nz1Fx+sH$F zQj1MZ)??yh`YIYve@~y%7`5rh$kxGQVIo}2zm+?#g3-;*gZ!y+BKGCxLMPy0v)i|0 zEe4%INj78ua;uBl1=mNNURj~PtHXoU;ZmSVyH16oPy5fjJGrbL*w>KEt03~Q5o?@o zXbIv##*BlhV@c;A%9o{)ZRUE3MSqz95V^YOfT>~XJ}_;=2o0j{W;!W&3n%& zwZPLM#kdT26gx(Ux6xEfO6tGv*INWn3bGA|pcN(80RN9^B9 z4xD2gq*;#lV_*G^jlcK5O?R++2Tq6~r7*c9RD#!Ayk!9we zV#CZ!>88pfv}eP43~=40H6Sr~errh3SVL)EY(s1SV-*E3QI_;KjWcX20C(>6(he~+ zab+r)l59zzm*p^zGd6b4&Vp8(Y;0a9$X$KLHL7QQ^2Uj%`RZt#2TNX&2>R61M{7y> zmic*LQ7#t3SN_{}4~_iYr-r<9M6cOP!zED@l~cn)|3!OH67M3?A+~h6A>IInw|_Ss zC#^~q6)A4Da-%vxX#MxEXpmBEeDmsIb9`B-BCiTj>rjY9t@gi**q@s@ML?mI_5~CW zdq4p$uiET>Ad}k&)TL#KW6ezV%N#k zL3w}NJt4Z@)=MGR{}|`8Y{8ioNbTNsdvNnwTL7Z>{f>ue$Y{3R59w5!8H8}T70Nlx z0LgpDAq$lnQOMVa!C~^}9`;*k$h&C$p;0vUH%@Rh6-osV-B}NU5Jaxi{~%(UwtN0GJVOxixw$HKCLlQy z4ZI-uVIKSu?}D$~Iv5?@pvpU6MvFOJ8dlBWk9z02G)41bSLyt`OlSQM0jUYzK;})z z#cXXcuBmsOC%u<*u9cT z)H72Y2$V9?nu_s5O=n9*u?RF}R+^@RLOQa61>awAgVv)}GtcV!jzD2B9Y5NY9b(b> zAZP#2M&S=szfwk!kEM!Z#&*=v^qSzMjAuG}QASb+w$4v)f_gtruSVWx zyS0*3zR>Pk)(K+$NFi>|V^l;RJJfF$CHm|0P4WJL&wmHPeyhEh%9Car5gfVpAfCkB zZ!xWn>%TK8TU(z&VVsLQqe_*n18c$1HrHo${(Q~N>hsJ}1 z7OJ5kArYoPi&cQRq~o7r9DPqw3IcPW9;PJbe5Ix$>U62+tRhbbcUrp*3=DQ(1*c6i zmCb#e3G0iXzKCEIwo)V~>WiKg>U|MZZa3R3);Z$9Z z+SnI?*E6Xerp4L_5K<`iY!5&u&$2YS|MAi9?Z!>|cQ=00(15tq|C;IaKeO?)APGmL zzI9MKcWQNS3S`yvwLmdl^fc?;6Bm$jAXFp$-FiB(WfL_<>Vm&5H z2bCv8UKJOc$ZBI9WnKD5Zr^UY2!>6n_j?+LOFbM&Um!LZIl^povIlX0NQk9&giAPJ zO}|=%nUNdA7mA8-eX}>5NOxKST?76V&J`L-)^)x6{iw-j$Sahr#*E-YJHw=Hz?n3n zTYy@DP%)v>1dK-E^$0`J)lKc8rF!U|jci^>RyLVIk+Fgr+Ws zl={W?b*4_-e4jMz0ca{)C?<>BKK}=M-T@n8P9$L$2^kX;G3?_N4(+;pCP#0;Z^(o_ zQ5TB9!|{`Cb~5GJ+|f?c6BV8Q*wz?%j+cB6JH8q=OnSRc)s^X)AFMU);Tp{jn9*^H zf4>!E5ViP@7bNyK6=-}qtUBuD1&Gc~WU~+d6&;nvl)!qH2q-8aD<{%lO+-)>&8V79 zI7HMuI}b(WWpb4;Ce$3i`LPAhZ-1fv?ryu5yVu|WEk{eT!mL*1$%;uS(<{g811s!$ z{o`_@!NKkd8koAPZqw zMnh)ok!8heBgXu=3L1J=eaX8^Bj-uHHh<{nWnUt_Op8p{aGeb8|u zEd3c460+MDa(9aIH{dr18uR@ar2C+tUmQ_;Upc*So|Zp+&9_Bnt}Gq52+wbdeEXxp ztS~f&ka$=-jo;%U)aaCb`@i)YAr&&&0_CJ3orh9EC0A z+ua|c@Tsz7Pe^T;EUphKv?EK2J&71bMYE-oE~(F8s@4f5ibp5?dGHLA@$cBO-fWD= zb<-Je=yEiLmzDgRqxS^=n?7+FQ5=lK#%N;a2*7&I`G-qt^jm+U(S4k7K%<*F&u7+1 zkEB%p>i{@|0?7-jH@y4GIZW#{soRX&rjP%3N1`#4zqI#kP)U~*Ph^DHufx}8Vodtu z=ItuH+h5o#i_3F70RA$m!>V5bnO*Z$n=~duj$x+c!eqbVM(QRL@*RAzr9#^u;@+gp zHy$zhptXA}g|1jTU|`m1cYOu*{q9Pb#qbBG7SVFO7rR_`d<9on17)_8crl;bskIaP zb&G8n*(I+!p7ytEMRGJTRGl~saywf@j@zT-BJlee25k71>4P`YzFhyJxSnL0y%>3@ zeX~1Q0G_Qhvg4}2Z+m|(^`5oiQLRunm!udmq31raQhC&6Hm;$EIuv0`c7t)TJ_7_)yc2YVXIq5mhK&za(bH9DXxGTP$xVliWn}YUa2mcbU1ljHOYm=T+C6g z9h}VYvmJ2-y06`eB}I0*)?Sdwpxc&vHB`AsN?Aaq5}RWe2bnt%!9$umluS9cKR$Jg zaJ&gN*XuE(Xm!`hpL)O=(<&L49c=KV$NSqL7R$Gi=6>J&)`C`(I32`a^LlPS6?4=Pg_H$Z5LN&Zh^= zj5-c}LC0s)<&2J=UFBzhF|&R1!nNt?GL1M3ytA1uR&pnpA>&AB3b{O_XiO$QIJs&J z!nRdQv8d`BaO==O9#@;0hp>}tU!9$ukB*Y)ngvhDgN8416>{uHPXgo_a;@%@_`$(a zX6v$hLoozeZO&%xc9>NNpWlk?aoGKD@b7C3et`tzB%{Qr zEJGx6+xo*a5#nHjJ{{)h_#6VH{x5kKKyznKh>>g z=FAmLqkTKQo33-X(qHc+POulWv+HND_qkD_4B_^l*L48NWz^H6uu=e&!J1r#6{Ig> z1<>bm?-+F&|7JtqHR(KcQ{sbj;kTCwv#t+*b8Lb_QxBQnYz8HWRfS~1C%LD19>B!d%w9u-Fa3~G0;r`*6#LzJ({DxTfRG2hO zGxK=4f02XW5bdpcypR7kM5l*BCf}=M&DD_rFe82I&Zqw9Ii-!qdy6!3E1PWcrsObn0|ASUT>a(iHP< zc5X+=gU=Z>a*x}wIs63*7pp1<>0hf&s)+4X`11t-*!%Mp)%B_g7$!sZhrPHR1Cg3u z5BK56YTcW=#A21CB%CpQ=BLF9qy`zbguG*;@Y>L|G#les<$IQ&kI(*JFF3vizUof9 z@X8~PN(s_1FYk9Ii&fg7|H2TLzZ#NU%Y$H%;Yl#5E6i|@-|zNEgTC?f?HO!FD`U0U z1*05wCnk#EXh}$5kWiFH5_UsvitE@s?eT0=Wxsb^`QHq*X_30~lXgHcEWimxVk$7X zf&*7EHD?rqy8Px>#k%d;ueJt(9N(AQgF4R}W6DYGLu-ab@$tLpVCw_98xLfdB@>^Q z!8Aw5FTe6%bM@-9*W7fRS*X6kI7!=`9Ae#f|ZxVwc!be z_VM3(V%w>Es7kN?ny?wl;fDD{I_pRlzv627t#ts!T&&&N2pPvuOrd!wRhT4j=m8O7 zMy&uW;V(Ii*?v-&TGLqz5q}+5P69H|g&cHDOw2zmWfr2}giDUV^nF}Gu$NSUQxetj zVprBjP?eaQ27^ItlZ}RjOkof>fBu@=^EK(j^R3Om;7H20{bCjGD++G@ig8wYI*-RU zvA6Th?>lBKQEV(qug>xk2ghK1HTWCe&hfW6?4;;3?**^IP|Kq!J#xE(&;yUGD&Z2b7>2aI;i=->qSd8Ow@|Xss=?e! z=R;-hJD9M*^LMO>Xa$re6T}iy=^&ZT1dX2yO~Y= z7nsc9N%~9=r*^HkZtMux`s`;QGPoQE)-=FgQS@IPPf}vn# zx7Zy>NIH3685^(ep|bzV)9}xIz+q4ooo!4xIv^pjthKaBNwB`4I6==CuwDi^wfY{# zYTZ=zzpHL>j2qa_dloy8M0?-Kw;N@q=tAQ6J{hg-Ql>wilu9>^6i1^H6MJ5LJk4}W zSB}}27^U7m%~@;~md@p?V%K1s!aq?X^?`Y(BQYKU`Wt=MG!Q9-Vfu*ylwDI>3fz_7 zkYoDojT$C{7Anj-B(m#==NM*{Rx=?hD@vkF6R$B%>?Fbat6}sH`syKLdhB3oDd0Lm7iFfV`7#O`J=%^;HFKVIcNirBr)sttqVNWrg9dO9?`e>kOz zc|!r+AGM}CWCMc(BF&ct5$RT#5nccZL>$449IzI3V_17z2^I{1su!+@@%!`5*DSz{ zClXXE(j~d>5V;n8x!I&!6&V>EFCkJb{SrsAtkv75tnZ4^b{ap#gal(7P}yPs`Qg#Y z?amNO%$v!;Sq89in7O;Z9mwwuVhPUf%62mR;FZ>+KE?U&PW0+cM_pMH*2GFO#W}Y1 zgwu2*)q*76+ua$dqVs;Z(kb2nwrsArXLcfx$?yy1R_afq%8}VTCrMk|nbRzFAMTt7 zsHjAu4*Ouj(a68TzSDX-<6Ma@IGH6J>^mMOmW>B08`Ji3F^ueSKW8yeiGp?~pR>WH z)85dIwU+1UmFu+?dt^MV-|@AP=VjZ`_Ssq5tzA&T`R>(5hq&F~H}7SvDHHE*9Y2?w z-E`%94X%Iwpd!Pn=r_R&{9vS^1BiKNj@Xf^DqtB044)WLCio;kKOooVnHcu?wYop( zB#uxQ8fc&HaoQ|mnr!_-S*W<7Do2j%RpRO;)t@Vo?|&3-`SE($MS-T6TF`p3iDTd& zlWM0USj9dX_u>hfj<~c)-ZYJo<5v%d=6*!0+`loeKOKak+vC-2OuQ6xW_kM}H@? z{D!&NEp~kz!_zqhBH=X9>&QcQSs(81`VH}i!GjEfT3Y5}-98KQ!u6>mN9864M$pfy z+g0mkPav!FcfK5^iKEE}pUzj3FC5S11H%w~8|yWyw7|cuv4QcfUxki4WVKPYwMjoa zys>&P$QiZUfxwF_^kijet{Qa4ou_PTVBrJRhGDYEf( zch1bferb5ixBT1hCLep%{UYJ|5*`z!+LyvH@_`L0Np>uoCB&*rr5asgHxF0X{kGs~U+8_MY^~N?S#vLD z#T%Dt)k_*1cG&Qc-CeJiyCJi}6FxWFFi zmtfP)iK^;I#z`4oZ5q^d=!t=+pwa~j)Y0&2F1rZKP)iMWQduNWhG24vHfJ~mvT8T;g zA{Rlip_oV--R|tYM|!6994I!YhD}^&E+`9yyrH`OFsJ8bW#^$#gvy+d7uA04&|TN` zqYn4MGeB5=s}z_zbFWuuyXZYva_%Hk7{*MKGc`XG`C>3leE6L-GOAtaRq81mV-Q2N z#N3aAf-o=O#({A1se>Doy9#^WoeaiDM*N2@be(%EfHL1&;V*o)bPjyLIt_|yE9nRaW=%SmS@ zmqTm;APmHH66WXEJ?ck^Bl5)j+Y%m+qqTCIQShGfwY&SC1!Js}M?npF6uPqw;gK1* z4u8i9QjF*>-SWj?`mx4>83a>Hm^@*CT(jVA*9;W(jye0mpj(f*)@+aUnQ@O_KQW-h zfDVHV2V8THEBrN0{N3m1-pWyJ43VW{*UeCD-dO1?tpu=B%aXt#Fup@Ou5g87&Z zJB)ca`kOIYrN-tab#aA@S#wu56)wgxwu4HD_)@WNdyKhf@Ro5^wb1CWywlcm4g__! zCv1eg&I5-=-cg!UJ~_GE$#^9kFN@ysBqyWPd14XhgE^z>^|0N*8oxn|m%!Xl%1qd4 zfYYDG-(-i3DtSu*#=lOAy17#QJm`o>z(Q8K^LvJSrA_%m>jPlIVPiFm4;?udgcl}+ zq~X6sT+e^h$0?PXDfUrPZTzMSiDnZ(Xap>=C=JG&G5SCG-jkWvW5wlojSS0?!|hl2 zt-U?Kz3D{wG&y6zh!|At&fCcWoBoKmh-I% zhIHJ&=jPf7xudj59aTdaW4>U#|0}w(7n{!bovzJ889peYPs+pZLq9X_IwAkSPBH`d z1)@G~;&+7jUP8=}|9ki6{{?vjhWly?QA;?pTcu%Yx4dpPC*lr?gr`rQ#^1Yl;o!l8 zg3)f>zLm6lrG<=0)>d|*f+$g{6q+_`ibf3^p(g1?997R&MK{ihV?=CpSPh*F4{3q! z`BHkQ3uQ-QFhe~BC3KCgdaPr0qjT6}^%*u`@Q(H@y?zf)AV)A1L-j}WCt@p3MYsdz z_9EEZ6)Ja^rcnR09UBmt8l&wqMW|gxPMgfEaIUt|_DpaU|>NRQzqXJWT z@`H)k!?YF*{lFLfBb5GN=hGt=Pi>aYyI?~ox)C!}EBftH^d9$n-c7WNs0%ZXx{I+f zf{hq&I?L=ihM_FrLNMgZ`G5wjA5k9Y33HmiDV(e~BO-8yoUUxt;OK=*mx#%T@ggH5 zbrBKyJN*6qQKVQ=5vW_CVkOk5ULDn|SEJX8Ay4i+v;%oQZ02?&L(w^HA`9L`+WEM| zmrG7sjlWxo9!?QWg3^71L)8RBsqfE#5^UR%Ui+Q??hM6@-kJX13@4G}7|Md^=;$)O zzP|6%)2E@OYOOuUl?H@WXEE{g0qt55H*X>$;)X~szZH2)Bt$X^CoYSP>apzb^(L{* z$IlPB^W+va30YKQ3M>?ey!n)+i77|684QRzZ z5~Kg4$LG$SJLhXw^pQ*fI+p;ma$i|bx~)E+O!NcMMYFPyZY8P;9gt&1K3h;HLIt9J zM6VK+fB}18W5X7rFM@-EcUP)ZDb|#MMJCgNSusd8BI-lbn1~C5uvo`d#~+D)*DIag zU?>})8^GhjMI+do&h-oG!hkg*Xh)hw7DBe1aiNYZk*mZoE0HuDLBk9bt@n8w4CMf3 zMGs(x>O<6yh~?}|QxiTN!41d%CR#xj%guT7=FPNq9WoLILumjp7w6M(8*+9Ga_EYD zW!tO?uD!K=jTLXUYH`3P_fa}bMuo#YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<_}q ah5rL_vqsH3O^sLp0000() => { + 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 +
+