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`?^8Az
• Drag markers to move workouts to new locations
- • Click on the map (and then again on the new marker) to add a new
+ • Click on the map to place a pin and see options for adding a new
location
diff --git a/apps/map/src/app/_components/map/location-edit-buttons.tsx b/apps/map/src/app/_components/map/location-edit-buttons.tsx
new file mode 100644
index 00000000..da35261e
--- /dev/null
+++ b/apps/map/src/app/_components/map/location-edit-buttons.tsx
@@ -0,0 +1,306 @@
+import { ArrowRight, CirclePlus, Edit, Trash } from "lucide-react";
+
+import { Button } from "@acme/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@acme/ui/dropdown-menu";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { openRequestModal } from "~/utils/open-request-modal";
+import { appStore } from "~/utils/store/app";
+import { useFilteredMapResults } from "./filtered-map-results-provider";
+
+interface LocationEditButtonsProps {
+ locationId: number;
+ eventId?: number | null;
+ aoId?: number | null;
+ aoName?: string;
+ eventName?: string;
+ timeDisplay?: string;
+ eventCount?: number;
+}
+
+export const LocationEditButtons = ({
+ locationId,
+ eventId,
+ aoId,
+ aoName = "AO",
+ eventName = "Workout",
+ timeDisplay = "",
+ eventCount = 0,
+}: LocationEditButtonsProps) => {
+ const mode = appStore.use.mode();
+ const { locationOrderedLocationMarkers } = useFilteredMapResults();
+
+ const { data: workoutInfo } = useQuery(
+ orpc.map.location.locationWorkout.queryOptions({
+ input: { locationId: locationOrderedLocationMarkers?.[0]?.id ?? -1 },
+ enabled: mode === "edit" && !!locationOrderedLocationMarkers?.[0]?.id,
+ }),
+ );
+
+ return (
+
+ {/* Add Event Button */}
+
+
+
+ {/* Event Edit Button - only show if an event is selected */}
+ {eventId && (
+
+
+
+
+
+ {
+ // Need locationId to query the event
+ void openRequestModal({
+ type: "edit_event",
+ eventId,
+ locationId,
+ });
+ }}
+ >
+
+
+
+ Edit workout details
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_event_to_different_ao",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ newAoId:
+ workoutInfo?.location.events[0]?.aoId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move to different AO
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_event_to_new_ao",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ newAoId:
+ workoutInfo?.location.events[0]?.aoId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move to a new AO
+
+
+
+
+
+
+ {
+ void openRequestModal({
+ type: "delete_event",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Delete this workout
+
+
+
+
+
+ )}
+
+ {/* AO Edit Button */}
+
+
+
+
+ {
+ void openRequestModal({
+ type: "edit_ao_and_location",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Edit AO details
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_ao_to_different_location",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Move AO to different location
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_ao_to_different_region",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move AO to different region
+
+
+
+
+
+
+ {
+ void openRequestModal({ type: "delete_ao", locationId, aoId });
+ }}
+ >
+
+
+
+ Delete this AO
+
+
+
+
+
+
+ );
+};
+
+export const getShortDayOfWeek = (day: string | null | undefined) => {
+ if (!day) return "";
+
+ switch (day.toLowerCase()) {
+ case "monday":
+ return "M";
+ case "tuesday":
+ return "Tu";
+ case "wednesday":
+ return "W";
+ case "thursday":
+ return "Th";
+ case "friday":
+ return "F";
+ case "saturday":
+ return "Sa";
+ case "sunday":
+ return "Su";
+ default:
+ return "";
+ }
+};
+
+export const formatTime = (time: string | null | undefined) => {
+ if (!time || time.length !== 4) return "";
+
+ const hour = parseInt(time.substring(0, 2));
+ const minute = time.substring(2, 4);
+ const period = hour >= 12 ? "p" : "a";
+ const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
+
+ return `${displayHour}:${minute}${period}`;
+};
diff --git a/apps/map/src/app/_components/map/update-pane.tsx b/apps/map/src/app/_components/map/update-pane.tsx
index e3bf3e2a..1e1c9de0 100644
--- a/apps/map/src/app/_components/map/update-pane.tsx
+++ b/apps/map/src/app/_components/map/update-pane.tsx
@@ -1,56 +1,158 @@
import { AdvancedMarker } from "@vis.gl/react-google-maps";
-import { MapPinPlusInside } from "lucide-react";
+import { ArrowDownToDot, MapPin, MapPinPlusInside, X } from "lucide-react";
import { Z_INDEX } from "@acme/shared/app/constants";
import { TestId } from "@acme/shared/common/enums";
+import { Button } from "@acme/ui/button";
+import { toast } from "@acme/ui/toast";
+import { orpc, useQuery } from "~/orpc/react";
+import { openRequestModal } from "~/utils/open-request-modal";
import { appStore } from "~/utils/store/app";
import { mapStore } from "~/utils/store/map";
-import {
- eventDefaults,
- locationDefaults,
- ModalType,
- openModal,
-} from "~/utils/store/modal";
+import { useFilteredMapResults } from "./filtered-map-results-provider";
export const UpdatePane = () => {
const updateLocation = mapStore.use.updateLocation();
+ const { locationOrderedLocationMarkers } = useFilteredMapResults();
const mode = appStore.use.mode();
+ const { data: workoutInfo } = useQuery(
+ orpc.map.location.locationWorkout.queryOptions({
+ input: { locationId: locationOrderedLocationMarkers?.[0]?.id ?? -1 },
+ enabled: mode === "edit" && !!locationOrderedLocationMarkers?.[0]?.id,
+ }),
+ );
+
+ // Function to clear the update location pin
+ const clearUpdateLocation = (e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ mapStore.setState({ updateLocation: null });
+ };
+
+ // Create new location with new AO and event
+ const handleCreateNew = async () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "create_ao_and_location_and_event",
+ meta: { originalRegionId: workoutInfo?.location.regionId ?? undefined },
+ });
+ };
+
+ // Move existing AO to this location
+ const handleMoveAO = () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "move_ao_to_new_location",
+ meta: {
+ originalRegionId: workoutInfo?.location.regionId ?? undefined,
+ originalAoId: workoutInfo?.location.events[0]?.aoId ?? undefined,
+ originalLocationId: workoutInfo?.location.id ?? undefined,
+ },
+ });
+ };
+
+ // Move existing event to new AO here
+ const handleMoveEvent = () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "move_event_to_new_location",
+ meta: {
+ originalRegionId: workoutInfo?.location.regionId ?? undefined,
+ originalAoId: workoutInfo?.location.events[0]?.aoId ?? undefined,
+ originalLocationId: workoutInfo?.location.id ?? undefined,
+ },
+ });
+ };
+
+ if (!updateLocation || mode !== "edit") return null;
+
return (
-
- {updateLocation && mode === "edit" ? (
- <>
-
{
- if (!e.latLng) throw new Error("No latLng");
- openModal(ModalType.UPDATE_LOCATION, {
- requestType: "create_location",
- ...eventDefaults,
- ...locationDefaults,
- lat: e.latLng.lat(),
- lng: e.latLng.lng(),
- });
- }}
- onDragEnd={(e) => {
- if (!e.latLng) throw new Error("No latLng");
- mapStore.setState({
- updateLocation: {
- lat: e.latLng.lat(),
- lng: e.latLng.lng(),
- },
- });
- }}
- position={updateLocation}
- >
-
-
- >
- ) : null}
-
+ {
+ if (!e.latLng) throw new Error("No latLng");
+ mapStore.setState({
+ updateLocation: {
+ lat: e.latLng.lat(),
+ lng: e.latLng.lng(),
+ },
+ });
+ }}
+ position={updateLocation}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx b/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
index 7218d5c1..314ebd3e 100644
--- a/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
+++ b/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
@@ -1,7 +1,8 @@
import type Supercluster from "supercluster";
import { useCallback, useMemo } from "react";
import { useMap } from "@vis.gl/react-google-maps";
-import { CLOSE_ZOOM } from "node_modules/@acme/shared/src/app/constants";
+
+import { CLOSE_ZOOM } from "@acme/shared/app/constants";
import type {
F3ClusterProperties,
diff --git a/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx b/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx
deleted file mode 100644
index 0f733cc8..00000000
--- a/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { Trash } from "lucide-react";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { invalidateQueries, orpc, ORPCError, useQuery } from "~/orpc/react";
-import { closeModal, modalStore } from "~/utils/store/modal";
-
-export default function AdminDeleteRequestModal({
- data: requestData,
-}: {
- data: DataType[ModalType.ADMIN_DELETE_REQUEST];
-}) {
- const { data: regions } = useQuery(
- orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }),
- );
- const router = useRouter();
- const [status, setStatus] = useState<"approving" | "rejecting" | "idle">(
- "idle",
- );
- const { data: request } = useQuery(
- orpc.request.byId.queryOptions({ input: { id: requestData.id } }),
- );
-
- const onReject = async () => {
- setStatus("rejecting");
- try {
- await orpc.request.rejectSubmission.call({
- id: requestData.id,
- });
- void invalidateQueries({
- predicate: (query) => query.queryKey[0] === "request",
- });
- router.refresh();
- toast.error("Rejected delete request");
- closeModal();
- } catch (error) {
- if (error instanceof ORPCError) {
- toast.error(error.message);
- } else {
- toast.error("Failed to reject delete request");
- }
- console.error(error);
- } finally {
- setStatus("idle");
- }
- };
-
- const onDelete = async () => {
- setStatus("approving");
- if (!request) {
- toast.error("Request not found");
- setStatus("idle");
- return;
- } else if (request.eventId == undefined || request.regionId == undefined) {
- toast.error("Request is missing eventId or regionId");
- setStatus("idle");
- return;
- } else if (request.requestType !== "delete_event") {
- toast.error("Request is not a delete workout request");
- setStatus("idle");
- return;
- }
-
- try {
- await orpc.request.validateDeleteByAdmin.call({
- eventId: request.eventId,
- eventName: request.eventName,
- regionId: request.regionId,
- submittedBy: request.submittedBy,
- });
-
- void invalidateQueries(orpc.request.all.queryOptions());
- router.refresh();
- toast.success("Delete request submitted");
- modalStore.setState({ modals: [] });
- } catch (error) {
- console.error(error);
- if (error instanceof ORPCError) {
- toast.error(error.message);
- } else {
- toast.error("Failed to submit delete request");
- }
- console.error(error);
- } finally {
- setStatus("idle");
- }
- };
-
- if (!request) return Loading...
;
-
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/admin-requests-modal.tsx b/apps/map/src/app/_components/modal/admin-requests-modal.tsx
deleted file mode 100644
index da216338..00000000
--- a/apps/map/src/app/_components/modal/admin-requests-modal.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
-import { v4 as uuid } from "uuid";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import {
- convertHH_mmToHHmm,
- convertHHmmToHH_mm,
-} from "@acme/shared/app/functions";
-import { isProd } from "@acme/shared/common/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Form } from "@acme/ui/form";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import type { DataType, ModalType } from "~/utils/store/modal";
-import {
- invalidateQueries,
- orpc,
- ORPCError,
- useMutation,
- useQuery,
-} from "~/orpc/react";
-import { useUpdateLocationForm } from "~/utils/forms";
-import { closeModal } from "~/utils/store/modal";
-import { FormDebugData, LocationEventForm } from "../forms/location-event-form";
-
-export default function AdminRequestsModal({
- data: requestData,
-}: {
- data: DataType[ModalType.ADMIN_REQUESTS];
-}) {
- const router = useRouter();
- const [status, setStatus] = useState<"approving" | "rejecting" | "idle">(
- "idle",
- );
- const { data: request } = useQuery(
- orpc.request.byId.queryOptions({ input: { id: requestData.id } }),
- );
- const form = useUpdateLocationForm({
- defaultValues: { id: request?.id ?? uuid() },
- });
-
- const formId = form.watch("id");
-
- const { data: eventTypes } = useQuery(
- orpc.eventType.all.queryOptions({ input: undefined }),
- );
-
- const validateSubmissionByAdmin = useMutation(
- orpc.request.validateSubmissionByAdmin.mutationOptions(),
- );
- const rejectSubmissionByAdmin = useMutation(
- orpc.request.rejectSubmission.mutationOptions(),
- );
-
- const onSubmit = form.handleSubmit(
- async (values) => {
- try {
- setStatus("approving");
- await validateSubmissionByAdmin.mutateAsync({
- ...values,
- eventStartTime: convertHH_mmToHHmm(values.eventStartTime ?? ""),
- eventEndTime: convertHH_mmToHHmm(values.eventEndTime ?? ""),
- });
- void invalidateQueries({
- predicate: (query) =>
- query.queryKey[0] === "request" ||
- query.queryKey[0] === "event" ||
- query.queryKey[0] === "location",
- });
- router.refresh();
- toast.success("Approved update");
- closeModal();
- } catch (error) {
- console.log(error);
- if (!(error instanceof ORPCError)) {
- toast.error("Failed to approve update");
- return;
- }
-
- if (error.message.includes("End time must be after start time")) {
- form.setError("eventEndTime", {
- message: "End time must be after start time",
- });
- throw new Error("End time must be after start time");
- } else {
- toast.error("Failed to approve update");
- }
- } finally {
- setStatus("idle");
- }
- },
- (error) => {
- toast.error("Failed to approve update");
- console.log(error);
- },
- );
-
- const onReject = async () => {
- setStatus("rejecting");
- console.log("rejecting");
- await rejectSubmissionByAdmin
- .mutateAsync({
- id: formId,
- })
- .then(() => {
- void invalidateQueries({
- predicate: (query) => query.queryKey[0] === "request",
- });
- router.refresh();
- setStatus("idle");
- toast.error("Rejected update");
- closeModal();
- });
- };
-
- useEffect(() => {
- if (!request) return;
- form.reset({
- id: request.id,
- requestType: request.requestType,
- eventId: request.eventId ?? null,
- locationId: request.locationId ?? null,
- eventName: request.eventName ?? "",
- // workoutWebsite: request.web ?? "",
- locationAddress: request.locationAddress ?? "",
- locationAddress2: request.locationAddress2 ?? "",
- locationCity: request.locationCity ?? "",
- locationState: request.locationState ?? "",
- locationZip: request.locationZip ?? "",
- locationCountry: request.locationCountry ?? "",
- locationLat: request.locationLat ?? 0,
- locationLng: request.locationLng ?? 0,
- locationDescription: request.locationDescription ?? "",
- eventStartTime: convertHHmmToHH_mm(request.eventStartTime ?? ""),
- eventEndTime: convertHHmmToHH_mm(request.eventEndTime ?? ""),
- eventDayOfWeek: request.eventDayOfWeek ?? "monday",
- eventTypeIds: request.eventTypeIds ?? [],
- eventDescription: request.eventDescription ?? "",
- regionId: request.regionId ?? null,
- aoId: request.aoId ?? null,
- aoName: request.aoName ?? "",
- aoLogo: request.aoLogo ?? "",
- aoWebsite: request.aoWebsite ?? "",
- submittedBy: request.submittedBy ?? "",
- });
- }, [request, form, eventTypes]);
-
- if (!request) return Loading...
;
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/admin-workouts-modal.tsx b/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
index 83a99bac..c506465e 100644
--- a/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
+++ b/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
@@ -62,10 +62,10 @@ import { ControlledTimeInput } from "../time-input";
import { VirtualizedCombobox } from "../virtualized-combobox";
const EventInsertForm = EventInsertSchema.extend({
- startTime: z.string().regex(/^\d{2}:\d{2}$/, {
+ startTime: z.string().refine((val) => !val || /^\d{2}:\d{2}$/.test(val), {
message: "Start time must be in 24hr format (HH:mm)",
}),
- endTime: z.string().regex(/^\d{2}:\d{2}$/, {
+ endTime: z.string().refine((val) => !val || /^\d{2}:\d{2}$/.test(val), {
message: "End time must be in 24hr format (HH:mm)",
}),
eventTypeIds: z
diff --git a/apps/map/src/app/_components/modal/base-modal.tsx b/apps/map/src/app/_components/modal/base-modal.tsx
new file mode 100644
index 00000000..e7536cc1
--- /dev/null
+++ b/apps/map/src/app/_components/modal/base-modal.tsx
@@ -0,0 +1,40 @@
+import { Z_INDEX } from "@acme/shared/app/constants";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@acme/ui/dialog";
+
+import { closeModal } from "~/utils/store/modal";
+
+export const BaseModal = ({
+ children,
+ title,
+}: {
+ children: React.ReactNode;
+ title?: React.ReactNode;
+}) => {
+ return (
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/country-select.tsx b/apps/map/src/app/_components/modal/country-select.tsx
index 0767d1b9..03d8bcc2 100644
--- a/apps/map/src/app/_components/modal/country-select.tsx
+++ b/apps/map/src/app/_components/modal/country-select.tsx
@@ -29,7 +29,9 @@ export const CountrySelect = ({
disabled = false,
}: CountrySelectProps) => {
const sortedCountries = useMemo(() => {
- return [...COUNTRIES].sort((a, b) => a.name.localeCompare(b.name));
+ return [...COUNTRIES].sort((a, b) =>
+ a.code === "US" ? -1 : b.code === "US" ? 1 : a.name.localeCompare(b.name),
+ );
}, []);
return (
diff --git a/apps/map/src/app/_components/modal/delete-modal.tsx b/apps/map/src/app/_components/modal/delete-modal.tsx
deleted file mode 100644
index 3cea10d6..00000000
--- a/apps/map/src/app/_components/modal/delete-modal.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import { cn } from "@acme/ui";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { closeModal } from "~/utils/store/modal";
-
-export default function DeleteModal({
- data,
-}: {
- data: DataType[ModalType.DELETE_CONFIRMATION];
-}) {
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/loading-modal.tsx b/apps/map/src/app/_components/modal/loading-modal.tsx
new file mode 100644
index 00000000..8a6b61b5
--- /dev/null
+++ b/apps/map/src/app/_components/modal/loading-modal.tsx
@@ -0,0 +1,31 @@
+import { Z_INDEX } from "@acme/shared/app/constants";
+import { Dialog, DialogContent } from "@acme/ui/dialog";
+import { Loader } from "@acme/ui/loader";
+
+import { closeModal } from "~/utils/store/modal";
+
+/**
+ * Loading placeholder modal shown while modal data is being prepared
+ * Single Responsibility: Display loading state for modals
+ */
+export function LoadingModal() {
+ return (
+
+ );
+}
diff --git a/apps/map/src/app/_components/modal/modal-switcher.tsx b/apps/map/src/app/_components/modal/modal-switcher.tsx
index 01ea5b1b..50123abc 100644
--- a/apps/map/src/app/_components/modal/modal-switcher.tsx
+++ b/apps/map/src/app/_components/modal/modal-switcher.tsx
@@ -12,25 +12,35 @@ import AdminAOsModal from "./admin-aos-modal";
import AdminApiKeysModal from "./admin-api-keys-modal";
import AdminAreasModal from "./admin-areas-modal";
import AdminDeleteModal from "./admin-delete-modal";
-import AdminDeleteRequestModal from "./admin-delete-request-modal";
import AdminEventTypesModal from "./admin-event-types-modal";
import AdminGrantAccessModal from "./admin-grant-access-modal";
import AdminLocationsModal from "./admin-locations-modal";
import AdminNationsModal from "./admin-nations-modal";
import AdminRegionsModal from "./admin-regions-modal";
-import AdminRequestsModal from "./admin-requests-modal";
import AdminSectorsModal from "./admin-sectors-modal";
import AdminUsersModal from "./admin-users-modal";
import AdminWorkoutsModal from "./admin-workouts-modal";
-import DeleteModal from "./delete-modal";
+import DeleteConfirmationModal from "./delete-confirmation-modal";
import { EditModeInfoModal } from "./edit-mode-info-modal";
import { FullImageModal } from "./full-image-modal";
import HowToJoinModal from "./how-to-join-modal";
+import { LoadingModal } from "./loading-modal";
import { MapInfoModal } from "./map-info-modal";
import { QRCodeModal } from "./qr-code-modal";
import SettingsModal from "./settings-modal";
import SignInModal from "./sign-in-modal";
-import { UpdateLocationModal } from "./update-location-modal";
+import { CreateAOAndLocationAndEventModal } from "./update/create-ao-and-location-and-event-modal";
+import { CreateEventModal } from "./update/create-event-modal";
+import { DeleteAoModal } from "./update/delete-ao-modal";
+import { DeleteEventModal } from "./update/delete-event-modal";
+import { EditAoAndLocationModal } from "./update/edit-ao-and-location-modal";
+import { EditEventModal } from "./update/edit-event-modal";
+import { MoveAOToDifferentLocationModal } from "./update/move-ao-to-different-location-modal";
+import { MoveAOToDifferentRegionModal } from "./update/move-ao-to-different-region-modal";
+import { MoveAOToNewLocationModal } from "./update/move-ao-to-new-location-modal";
+import { MoveEventToDifferentAoModal } from "./update/move-event-to-different-ao-modal";
+import { MoveEventToNewAoModal } from "./update/move-event-to-new-ao-modal";
+import { MoveEventToNewLocationModal } from "./update/move-event-to-new-location-modal";
import UserLocationInfoModal from "./user-location-info-modal";
import { WorkoutDetailsModal } from "./workout-details-modal";
@@ -46,10 +56,58 @@ export const ModalSwitcher = () => {
return ;
case ModalType.USER_LOCATION_INFO:
return ;
- case ModalType.UPDATE_LOCATION:
+ case ModalType.EDIT_AO_AND_LOCATION:
return (
-
+ );
+ case ModalType.EDIT_EVENT:
+ return ;
+ case ModalType.CREATE_EVENT:
+ return (
+
+ );
+ case ModalType.CREATE_AO_AND_LOCATION_AND_EVENT:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_NEW_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_NEW_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_DIFFERENT_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_DIFFERENT_REGION:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_DIFFERENT_AO:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_NEW_AO:
+ return (
+
);
case ModalType.WORKOUT_DETAILS:
@@ -71,10 +129,6 @@ export const ModalSwitcher = () => {
data={data as DataType[ModalType.ADMIN_GRANT_ACCESS]}
/>
);
- case ModalType.ADMIN_REQUESTS:
- return (
-
- );
case ModalType.ADMIN_EVENTS:
return (
@@ -117,14 +171,16 @@ export const ModalSwitcher = () => {
);
case ModalType.DELETE_CONFIRMATION:
return (
-
+
);
- case ModalType.ADMIN_DELETE_REQUEST:
+ case ModalType.DELETE_EVENT:
return (
-
+
);
+ case ModalType.DELETE_AO:
+ return ;
case ModalType.QR_CODE:
return ;
case ModalType.ABOUT_MAP:
@@ -137,6 +193,8 @@ export const ModalSwitcher = () => {
return ;
case ModalType.EDIT_MODE_INFO:
return ;
+ case ModalType.LOADING:
+ return ;
default:
console.error(`Modal type ${type} not found`);
return null;
diff --git a/apps/map/src/app/_components/modal/update-location-modal.tsx b/apps/map/src/app/_components/modal/update-location-modal.tsx
deleted file mode 100644
index 5919e111..00000000
--- a/apps/map/src/app/_components/modal/update-location-modal.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
-import { useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
-import gte from "lodash/gte";
-import { useSession } from "next-auth/react";
-import { v4 as uuid } from "uuid";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import {
- convertHH_mmToHHmm,
- convertHHmmToHH_mm,
-} from "@acme/shared/app/functions";
-import { isProd } from "@acme/shared/common/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Form } from "@acme/ui/form";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import type { DataType, ModalType } from "~/utils/store/modal";
-import {
- invalidateQueries,
- orpc,
- ORPCError,
- useMutation,
- useQuery,
-} from "~/orpc/react";
-import { useUpdateLocationForm } from "~/utils/forms";
-import { appStore } from "~/utils/store/app";
-import { closeModal } from "~/utils/store/modal";
-import {
- DevLoadTestData,
- FormDebugData,
- LocationEventForm,
-} from "../forms/location-event-form";
-
-export const UpdateLocationModal = ({
- data,
-}: {
- data: DataType[ModalType.UPDATE_LOCATION];
-}) => {
- const router = useRouter();
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const { mutateAsync: submitUpdateRequest } = useMutation(
- orpc.request.submitUpdateRequest.mutationOptions(),
- );
-
- const form = useUpdateLocationForm({
- defaultValues: {
- locationCountry: "United States",
- },
- mode: "onBlur",
- });
-
- const formRegionId = form.watch("regionId");
- const { data: canEditRegion } = useQuery(
- orpc.request.canEditRegions.queryOptions({
- input: { orgIds: [formRegionId] },
- enabled: !!formRegionId && formRegionId !== -1,
- }),
- );
-
- const { data: session } = useSession();
- const { data: eventTypes } = useQuery(
- orpc.eventType.all.queryOptions({ input: undefined }),
- );
-
- const onSubmit = form.handleSubmit(
- async (values) => {
- try {
- console.log("onSubmit values", values);
- setIsSubmitting(true);
- if (values.badImage && !!values.aoLogo) {
- form.setError("aoLogo", { message: "Invalid image URL" });
- throw new Error("Invalid image URL");
- }
- appStore.setState({ myEmail: values.submittedBy });
-
- const updateRequestData = {
- ...values,
- eventStartTime: convertHH_mmToHHmm(values.eventStartTime ?? ""),
- eventEndTime: convertHH_mmToHHmm(values.eventEndTime ?? ""),
- eventId: gte(data.eventId, 0) ? data.eventId ?? null : null,
- };
-
- const result = await submitUpdateRequest(updateRequestData);
- if (result.status === "pending") {
- toast.success(
- "Request submitted. An admin will review your submission soon.",
- );
- } else if (result.status === "rejected") {
- toast.error("Failed to submit update request");
- throw new Error("Failed to submit update request");
- } else if (result.status === "approved") {
- void invalidateQueries({
- predicate: () => true,
- });
- toast.success("Update request automatically applied");
- router.refresh();
- }
-
- closeModal();
- } catch (error) {
- console.error(error);
- if (!(error instanceof Error)) {
- toast.error("Failed to submit update request");
- return;
- }
-
- if (!(error instanceof ORPCError)) {
- toast.error(error.message);
- return;
- }
-
- if (error.message.includes("End time must be after start time")) {
- form.setError("eventEndTime", {
- message: "End time must be after start time",
- });
- toast.error("End time must be after start time");
- throw new Error("End time must be after start time");
- } else {
- toast.error("Failed to submit update request");
- }
- } finally {
- setIsSubmitting(false);
- }
- },
- (errors) => {
- // Get all error messages
- const errorMessages = Object.entries(errors as { message: string }[])
- .map(([field, error]) => {
- if (error?.message) {
- return `${field}: ${error.message}`;
- }
- return null;
- })
- .filter(Boolean);
-
- // Show a toast with the first error message, or a generic message if none found
- toast.error(
-
- {errorMessages.map((error) => (
-
{error}
- ))}
-
,
- );
- console.log("Form validation errors:", errors);
- },
- );
-
- useEffect(() => {
- form.setValue("id", uuid());
- form.setValue("requestType", data.requestType);
- if (data.regionId != null && data.regionId !== -1) {
- form.setValue("regionId", data.regionId);
- } else {
- // @ts-expect-error -- must remove regionId from form
- form.setValue("regionId", null);
- }
-
- form.setValue("locationId", data.locationId ?? null);
- form.setValue("locationAddress", data.locationAddress ?? "");
- form.setValue("locationAddress2", data.locationAddress2 ?? "");
- form.setValue("locationCity", data.locationCity ?? "");
- form.setValue("locationState", data.locationState ?? "");
- form.setValue("locationZip", data.locationZip ?? "");
- form.setValue("locationCountry", data.locationCountry ?? "");
- form.setValue("locationLat", data.lat);
- form.setValue("locationLng", data.lng);
- form.setValue("locationDescription", data.locationDescription ?? "");
-
- form.setValue("aoId", data.aoId ?? null);
- form.setValue("aoName", data.aoName ?? "");
- form.setValue("aoLogo", data.aoLogo ?? "");
- form.setValue("aoWebsite", data.aoWebsite ?? "");
-
- form.setValue("eventId", data.eventId ?? null);
- form.setValue("eventName", data.workoutName ?? "");
- // startTime: convertHHmmToHH_mm(event?.startTime ?? ""),
- // endTime: convertHHmmToHH_mm(event?.endTime ?? ""),
- form.setValue("eventStartTime", convertHHmmToHH_mm(data.startTime ?? ""));
- form.setValue("eventEndTime", convertHHmmToHH_mm(data.endTime ?? ""));
- form.setValue("eventDescription", data.eventDescription ?? "");
- if (data.dayOfWeek) {
- form.setValue("eventDayOfWeek", data.dayOfWeek);
- } else {
- // @ts-expect-error -- must remove dayOfWeek from form
- form.setValue("eventDayOfWeek", null);
- }
- form.setValue("eventTypeIds", data.eventTypeIds);
-
- form.setValue("submittedBy", session?.email || appStore.get("myEmail"));
- }, [data, eventTypes, form, session?.email]);
-
- return (
-
- );
-};
diff --git a/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx b/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx
new file mode 100644
index 00000000..0c5abd11
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx
@@ -0,0 +1,61 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { CreateAOAndLocationAndEventType } from "@acme/validators/request-schemas";
+import { isProductionNodeEnv } from "@acme/shared/common/constants";
+import { Form } from "@acme/ui/form";
+import { CreateAOAndLocationAndEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { client } from "~/orpc/client";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { InRegionForm } from "../../forms/form-inputs/in-region-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const CreateAOAndLocationAndEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.CREATE_AO_AND_LOCATION_AND_EVENT];
+}) => {
+ console.log("CreateAOAndLocationAndEventModal data", data);
+ const form = useForm({
+ resolver: zodResolver(CreateAOAndLocationAndEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ const handleSubmission = async (values: CreateAOAndLocationAndEventType) => {
+ if ("badImage" in values && values.badImage && !!values.aoLogo) {
+ form.setError("aoLogo", { message: "Invalid image URL" });
+ throw new Error("Invalid image URL");
+ }
+
+ return await client.request.submitCreateAOAndLocationAndEventRequest(
+ values,
+ );
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/create-event-modal.tsx b/apps/map/src/app/_components/modal/update/create-event-modal.tsx
new file mode 100644
index 00000000..50576d47
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/create-event-modal.tsx
@@ -0,0 +1,44 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { CreateEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { CreateEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../base-modal";
+
+export const CreateEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.CREATE_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(CreateEventSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx b/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx
new file mode 100644
index 00000000..94bc9791
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx
@@ -0,0 +1,46 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { DeleteAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { DeleteAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { DeleteAoForm } from "../../forms/form-inputs/delete-ao-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../../modal/base-modal";
+
+export const DeleteAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.DELETE_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(DeleteAOSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/delete-event-modal.tsx b/apps/map/src/app/_components/modal/update/delete-event-modal.tsx
new file mode 100644
index 00000000..14012e0a
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/delete-event-modal.tsx
@@ -0,0 +1,46 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { DeleteEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { DeleteEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { DeleteEventForm } from "../../forms/form-inputs/delete-event-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../base-modal";
+
+export const DeleteEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.DELETE_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(DeleteEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx b/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx
new file mode 100644
index 00000000..6b0a0b60
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx
@@ -0,0 +1,54 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { EditAOAndLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { EditAOAndLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const EditAoAndLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.EDIT_AO_AND_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(EditAOAndLocationSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ const handleSubmission = async (values: EditAOAndLocationType) => {
+ if ("badImage" in values && values.badImage && !!values.aoLogo) {
+ form.setError("aoLogo", { message: "Invalid image URL" });
+ throw new Error("Invalid image URL");
+ }
+
+ return await vanillaApi.request.submitEditAOAndLocationRequest(values);
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/edit-event-modal.tsx b/apps/map/src/app/_components/modal/update/edit-event-modal.tsx
new file mode 100644
index 00000000..349ee5d0
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/edit-event-modal.tsx
@@ -0,0 +1,45 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { EditEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { EditEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const EditEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.EDIT_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(EditEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx
new file mode 100644
index 00000000..1c4bc084
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx
@@ -0,0 +1,53 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAOToDifferentLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToDifferentLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { ExistingLocationPickerForm } from "../../forms/form-inputs/existing-location-picker-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToDifferentLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_DIFFERENT_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToDifferentLocationSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ // TODO: Show the information about the ao that is being moved
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx
new file mode 100644
index 00000000..f263e31e
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx
@@ -0,0 +1,56 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAoToDifferentRegionType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToDifferentRegionSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { RegionSelector } from "../../forms/form-inputs/region-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToDifferentRegionModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_DIFFERENT_REGION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToDifferentRegionSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx
new file mode 100644
index 00000000..e50e8356
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx
@@ -0,0 +1,54 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAOToNewLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToNewLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionAndAOSelector } from "../../forms/form-inputs/region-and-ao-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToNewLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_NEW_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToNewLocationSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx
new file mode 100644
index 00000000..44d47624
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx
@@ -0,0 +1,59 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToDifferentAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToDifferentAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { RegionAndAOSelector } from "../../forms/form-inputs/region-and-ao-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToDifferentAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_DIFFERENT_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToDifferentAOSchema),
+ defaultValues: data,
+ });
+
+ console.log("MoveEventToDifferentAoModal data", data);
+ console.log("MoveEventToDifferentAoModal form", form.watch());
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx
new file mode 100644
index 00000000..c82b48e7
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx
@@ -0,0 +1,62 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToNewAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToNewAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { ExistingLocationPickerForm } from "~/app/_components/forms/form-inputs/existing-location-picker-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionSelector } from "../../forms/form-inputs/region-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToNewAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_NEW_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToNewAOSchema),
+ defaultValues: data,
+ });
+
+ const formNewLocationId = form.watch("newLocationId");
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx
new file mode 100644
index 00000000..0b06922c
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx
@@ -0,0 +1,55 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToNewLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToNewLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionAOEventSelector } from "../../forms/form-inputs/region-ao-event-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToNewLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_NEW_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToNewLocationSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/utils/handle-submission-error.ts b/apps/map/src/app/_components/modal/utils/handle-submission-error.ts
new file mode 100644
index 00000000..7957f33f
--- /dev/null
+++ b/apps/map/src/app/_components/modal/utils/handle-submission-error.ts
@@ -0,0 +1,60 @@
+import { ORPCError } from "@orpc/client";
+import isObject from "lodash/isObject";
+import { ZodError } from "zod";
+
+import { Case } from "@acme/shared/common/enums";
+import { convertCase } from "@acme/shared/common/functions";
+import { toast } from "@acme/ui/toast";
+
+export const handleSubmissionError = (error: unknown): void => {
+ let errorMessage: string;
+
+ if (error instanceof ZodError) {
+ console.error("handleSubmissionError ZodError", error);
+ const errorMessages = error.errors
+ .map((err) => {
+ if (err?.message) {
+ return `${err.path.join(".")}: ${err.message}`;
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ errorMessage =
+ errorMessages.length > 0
+ ? errorMessages.join(", ")
+ : "Form validation failed";
+ } else if (error instanceof ORPCError) {
+ console.error("handleSubmissionError error is an ORPCError", error);
+ errorMessage = error.message;
+ } else if (isObject(error)) {
+ console.error("handleSubmissionError error is object", error);
+ const errorMessages = Object.entries(
+ error as { message: string; type: string }[],
+ )
+ .map(([key, err]) => {
+ const keyWords = convertCase({ str: key, toCase: Case.TitleCase });
+ if (err?.message) {
+ return `${keyWords}: ${err.message}`;
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ errorMessage =
+ errorMessages.length > 0
+ ? errorMessages.join(", ")
+ : "Form validation failed";
+ } else if (!(error instanceof Error)) {
+ console.error("handleSubmissionError error is not an Error", error);
+ errorMessage = "Failed to submit update request";
+ } else if (!(error instanceof ORPCError)) {
+ console.error("handleSubmissionError error is not an ORPCError", error);
+ errorMessage = error.message;
+ } else {
+ console.error("handleSubmissionError else", error);
+ errorMessage = "Failed to submit update request";
+ }
+
+ toast.error(errorMessage);
+};
diff --git a/apps/map/src/app/_components/modal/workout-details-modal.tsx b/apps/map/src/app/_components/modal/workout-details-modal.tsx
index dbbfd813..a58b4a10 100644
--- a/apps/map/src/app/_components/modal/workout-details-modal.tsx
+++ b/apps/map/src/app/_components/modal/workout-details-modal.tsx
@@ -5,8 +5,14 @@ import { Dialog, DialogContent, DialogHeader } from "@acme/ui/dialog";
import type { DataType, ModalType } from "~/utils/store/modal";
import { orpc, useQuery } from "~/orpc/react";
+import { appStore } from "~/utils/store/app";
import { closeModal } from "~/utils/store/modal";
import { selectedItemStore } from "~/utils/store/selected-item";
+import {
+ formatTime,
+ getShortDayOfWeek,
+ LocationEditButtons,
+} from "../map/location-edit-buttons";
import { WorkoutDetailsContent } from "../workout/workout-details-content";
export const WorkoutDetailsModal = ({
@@ -16,6 +22,7 @@ export const WorkoutDetailsModal = ({
}) => {
const selectedLocationId = selectedItemStore.use.locationId();
const selectedEventId = selectedItemStore.use.eventId();
+ const mode = appStore.use.mode();
const providedLocationId =
typeof data.locationId === "number" ? data.locationId : -1;
const locationId = selectedLocationId ?? providedLocationId;
@@ -29,20 +36,52 @@ export const WorkoutDetailsModal = ({
results?.location.events.find((e) => e.id === selectedEventId)?.id ??
results?.location.events[0]?.id ??
null;
+ const modalAOIds = results?.location.events.map((e) => e.aoId);
+
const width = useWindowWidth();
const isLarge = width > Number(BreakPoints.LG);
const isMedium = width > Number(BreakPoints.MD);
+ // Get selected event details for edit buttons
+ const selectedEvent = results?.location.events.find(
+ (event) => event.id === modalEventId,
+ );
+ const eventName = selectedEvent?.name ?? "Workout";
+ const aoName = results?.location.parentName ?? "AO";
+ const aoId = selectedEvent?.aoId ?? modalAOIds?.[0] ?? null;
+ console.log("aoId", aoId, modalAOIds, results);
+
+ // Format time display
+ const shortDayOfWeek = getShortDayOfWeek(selectedEvent?.dayOfWeek);
+ const formattedTime = formatTime(selectedEvent?.startTime);
+ const timeDisplay =
+ shortDayOfWeek && formattedTime ? `${shortDayOfWeek} ${formattedTime}` : "";
+
return (