+
diff --git a/dashboard/src/data/events.ts b/dashboard/src/data/events.ts
index 9af852c0..973e238b 100644
--- a/dashboard/src/data/events.ts
+++ b/dashboard/src/data/events.ts
@@ -1,4 +1,4 @@
-import type { MyEvents } from "@/types"
+import type { EventDetail, MyEvents } from "@/types"
import { createResource, useCall } from "frappe-ui"
// v2 path: useCall reads the payload from `data`, which /api/method names `message`.
@@ -12,3 +12,24 @@ export function useMyEvents() {
export const createEvent = createResource<{ name: string; title: string }>({
url: "buzz.api.events.create_event",
})
+
+/** One event with everything its manage page edits. Per page, so it is not a singleton. */
+export function eventDetail(event: string) {
+ return createResource({
+ url: "buzz.api.events.get_event",
+ params: { event },
+ auto: true,
+ })
+}
+
+/**
+ * Save edits back onto an event.
+ *
+ * `set_value` takes a fieldname-to-value map, so the whole form travels as one write —
+ * and the team permission hooks guard Buzz Event, which is why this needs no endpoint of
+ * its own.
+ */
+export const updateEvent = createResource({ url: "frappe.client.set_value" })
+
+/** Whether an event can take a route. Routes are the public URL namespace, so they are unique. */
+export const checkEventRoute = createResource({ url: "buzz.api.events.check_event_route" })
diff --git a/dashboard/src/layouts/ManagerLayout.vue b/dashboard/src/layouts/ManagerLayout.vue
index 8a2a3236..346927c4 100644
--- a/dashboard/src/layouts/ManagerLayout.vue
+++ b/dashboard/src/layouts/ManagerLayout.vue
@@ -4,7 +4,7 @@ import UserMenu from "@/components/UserMenu.vue";
import { useTeamAccess } from "@/composables/useTeamAccess";
import NotFound from "@/pages/NotFound.vue";
import { DesktopShell, PageHeaderTarget, Sidebar, SidebarItem, SidebarLabel } from "frappe-ui";
-import { ref } from "vue";
+import { computed, ref } from "vue";
import { useRoute } from "vue-router";
const collapsed = ref(false);
@@ -30,6 +30,29 @@ const teamItems = [
{ label: "Events", icon: "lucide-calendar-days", to: "/manage/team/events" },
{ label: "Members", icon: "lucide-users-round", to: "/manage/team/members" },
];
+
+// An event opens into the same shell with its own destinations in place of the
+// personal and team ones.
+const eventId = computed(() => route.params.eventId as string | undefined);
+
+const eventItems = computed(() => [
+ { label: "Back", icon: "lucide-arrow-left", to: "/" },
+ {
+ label: "Details",
+ icon: "lucide-receipt-text",
+ to: `/manage/events/${eventId.value}/details`,
+ },
+ {
+ label: "Attendees",
+ icon: "lucide-users-round",
+ to: `/manage/events/${eventId.value}/attendees`,
+ },
+ {
+ label: "Talks",
+ icon: "lucide-presentation",
+ to: `/manage/events/${eventId.value}/talks`,
+ },
+]);
@@ -43,7 +66,18 @@ const teamItems = [
-
+
+
+
+
+
+import EventBanner from "@/components/dashboard/events/EventBanner.vue";
import EventLocation from "@/components/dashboard/events/EventLocation.vue";
import EventSchedule from "@/components/dashboard/events/EventSchedule.vue";
import { useTeamAccess } from "@/composables/useTeamAccess";
import { createEvent } from "@/data/events";
import { currentTeam } from "@/data/teams";
import NotFound from "@/pages/NotFound.vue";
-import { bannerPattern } from "@/utils/eventBanner";
import { isEndBeforeStart } from "@/utils/eventDates";
import { canCreateEvents } from "@/utils/teamRoles";
import { currentTimeZone } from "@/utils/timeZones";
-import { refDebounced } from "@vueuse/core";
import type { FrappeError } from "@/types";
-import { Alert, Button, ErrorMessage, FileUploader, toast, useTheme } from "frappe-ui";
+import { Alert, Button, ErrorMessage, toast, useTheme } from "frappe-ui";
import { Editor, EditorContent, RichTextKit } from "frappe-ui/editor";
-import { computed, ref, watch } from "vue";
+import { computed, ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
@@ -24,18 +23,6 @@ const access = useTeamAccess();
// letting someone fill it in and lose the work to a 403 on save.
const canCreate = computed(() => canCreateEvents(currentTeam.value?.team_role));
-// The picker is filtered to these, and the file that comes back is checked against the
-// same list: `accept` is a hint the OS may ignore, and a drag-drop never consults it.
-// Raster only — an SVG banner would be same-origin markup, which a banner has no need
-// to be. Neither check is enforcement; the upload endpoint takes what it is given.
-const BANNER_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
-
-function validateBannerImage(file: File): string | void {
- if (!BANNER_IMAGE_TYPES.includes(file.type)) {
- return "Choose a PNG, JPEG, WebP or GIF image";
- }
-}
-
const { currentTheme, setTheme, getSystemTheme } = useTheme();
// `system` resolves against the OS, so the icon shows what is on screen rather than
// what was picked — and the toggle sets the opposite outright instead of stepping
@@ -59,22 +46,6 @@ const venue = ref("");
// The Zoom meeting can only be booked once the event exists, so save has to act on this.
const zoomMeeting = ref(false);
-// The pattern is seeded by the title, so the draft banner settles into the one the
-// event keeps. Seeded off a debounced copy: a gradient cannot be transitioned, so
-// re-seeding per keystroke would redraw the banner once per character while the
-// organiser types. An untitled draft seeds on "Untitled" rather than the empty string,
-// which would draw every new event the same.
-const settledTitle = refDebounced(title, 250);
-
-const banner = computed(() => ({
- backgroundImage: bannerPattern(settledTitle.value.trim() || "Untitled"),
-}));
-
-// True from the first keystroke until the debounce fires — and the pattern swaps on the
-// same tick it turns false. So the blur is already up when the swap lands, and only
-// falls away afterwards, which is what hides the change.
-const isSettling = computed(() => title.value.trim() !== settledTitle.value.trim());
-
// About is optional: an event needs a name, when it runs, and where.
const canSave = computed(() =>
Boolean(
@@ -116,12 +87,6 @@ async function save() {
toast.success(`${createEvent.data?.title} created`);
router.push({ name: "team-events" });
}
-
-// Reset per image, so a second upload fades in rather than snapping.
-const bannerLoaded = ref(false);
-watch(bannerImage, () => {
- bannerLoaded.value = false;
-});
@@ -174,62 +139,7 @@ watch(bannerImage, () => {
:dismissible="false"
/>
- (bannerImage = file.file_url)"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+import EventBanner from "@/components/dashboard/events/EventBanner.vue";
+import EventMedium from "@/components/dashboard/events/EventMedium.vue";
+import EventRoute from "@/components/dashboard/events/EventRoute.vue";
+import EventSchedule from "@/components/dashboard/events/EventSchedule.vue";
+import { eventDetail, updateEvent } from "@/data/events";
+import type { EventDetail, FrappeError } from "@/types";
+import { Breadcrumbs, Button, ErrorMessage, PageHeader, Textarea, toast } from "frappe-ui";
+import { Editor, EditorContent, RichTextKit } from "frappe-ui/editor";
+import { computed, reactive, ref, watch } from "vue";
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+const eventId = route.params.eventId as string;
+
+const event = eventDetail(eventId);
+
+// The form the page edits, and the copy it is compared against to know it is dirty.
+const form = reactive(blank());
+const saved = ref(JSON.stringify(blank()));
+
+function blank() {
+ return {
+ title: "",
+ route: "",
+ short_description: "",
+ about: "",
+ banner_image: "",
+ start_date: "",
+ start_time: "",
+ end_date: "",
+ end_time: "",
+ time_zone: "",
+ medium: "In Person",
+ venue: "",
+ meeting_link: "",
+ };
+}
+
+// Nulls all the way through the payload; the form works in empty strings so an untouched
+// field compares equal to itself.
+function fill(detail: EventDetail) {
+ Object.assign(form, {
+ title: detail.title ?? "",
+ route: detail.route ?? "",
+ short_description: detail.short_description ?? "",
+ about: detail.about ?? "",
+ banner_image: detail.banner_image ?? "",
+ start_date: detail.start_date ?? "",
+ start_time: detail.start_time ?? "",
+ end_date: detail.end_date ?? "",
+ end_time: detail.end_time ?? "",
+ time_zone: detail.time_zone ?? "",
+ medium: detail.medium || "In Person",
+ venue: detail.venue?.name ?? "",
+ meeting_link: detail.meeting_link ?? "",
+ });
+ saved.value = JSON.stringify(form);
+}
+
+watch(
+ () => event.data,
+ (detail) => detail && fill(detail)
+);
+
+const isDirty = computed(() => JSON.stringify(form) !== saved.value);
+
+const items = computed(() => [{ label: event.data?.title || "Event" }, { label: "Details" }]);
+
+// createResource types its error as {}, so the message needs narrowing.
+const errorMessage = computed(() =>
+ (updateEvent.error as FrappeError | null)?.messages?.join("\n")
+);
+
+async function save() {
+ // A blank date or venue has to reach the server as null, not "".
+ const fieldname = Object.fromEntries(
+ Object.entries(form).map(([field, value]) => [field, value === "" ? null : value])
+ );
+
+ await updateEvent.submit({ doctype: "Buzz Event", name: eventId, fieldname });
+ if (updateEvent.error) return;
+
+ saved.value = JSON.stringify(form);
+ toast.success("Event saved");
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
About
+
+
+
+
+
+
+
+
+
+
+
+ When
+
+
+
+
+
+
+ Where
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/manage/teams/TeamEvents.vue b/dashboard/src/pages/manage/teams/TeamEvents.vue
index 6451a52c..9c6f1094 100644
--- a/dashboard/src/pages/manage/teams/TeamEvents.vue
+++ b/dashboard/src/pages/manage/teams/TeamEvents.vue
@@ -34,9 +34,10 @@ const months = computed(() =>
:loading="myEvents.loading"
:error="myEvents.error"
>
-
+
-
+
diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts
index 7265e670..25340e89 100644
--- a/dashboard/src/router.ts
+++ b/dashboard/src/router.ts
@@ -35,6 +35,19 @@ const routes: RouteRecordRaw[] = [
name: "events",
component: () => import("@/pages/manage/MyEvents.vue"),
},
+ {
+ path: "events/:eventId",
+ redirect: (to) => `/manage/events/${to.params.eventId}/details`,
+ },
+ {
+ path: "events/:eventId/details",
+ name: "event-details",
+ component: () => import("@/pages/manage/events/EventDetails.vue"),
+ },
+ {
+ path: "events/:eventId/:section",
+ component: () => import("@/pages/manage/WorkInProgress.vue"),
+ },
{
path: "tickets",
name: "tickets",
diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts
index 50c99fe7..2c3023a1 100644
--- a/dashboard/src/types.ts
+++ b/dashboard/src/types.ts
@@ -125,6 +125,31 @@ export interface MyEvents {
past: MyEvent[]
}
+export interface EventVenueDetail {
+ name: string
+ address: string | null
+}
+
+// buzz.api.events.get_event: one event with everything its manage page edits.
+export interface EventDetail {
+ name: string
+ title: string
+ route: string | null
+ team: string | null
+ start_date: string
+ end_date: string | null
+ start_time: string | null
+ end_time: string | null
+ time_zone: string | null
+ short_description: string | null
+ about: string | null
+ banner_image: string | null
+ medium: string | null
+ venue: EventVenueDetail | null
+ meeting_link: string | null
+ is_published: boolean
+}
+
// A ticket the user holds, flattened with the context its event carries.
export interface TicketStub {
name: string
diff --git a/e2e/tests/create-event.spec.ts b/e2e/tests/create-event.spec.ts
new file mode 100644
index 00000000..086c2d5e
--- /dev/null
+++ b/e2e/tests/create-event.spec.ts
@@ -0,0 +1,17 @@
+import { expect, test } from "@playwright/test";
+
+// The create page and the event details page share EventBanner, so this is the guard
+// that the extraction did not cost the create form its banner.
+test.describe("Create event", () => {
+ test("offers a banner, a title and a way to save", async ({ page }) => {
+ await page.goto("/b/manage/events");
+ await page.getByRole("link", { name: "Events", exact: true }).click();
+ await page.getByRole("link", { name: "Create Event" }).click();
+
+ await expect(page).toHaveURL(/\/b\/manage\/team\/events\/new$/, { timeout: 15000 });
+ await expect(page.getByRole("button", { name: "Add a banner" })).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Event title" })).toBeVisible();
+ // Nothing is filled in yet, so the page must not offer to create anything.
+ await expect(page.getByRole("button", { name: "Create event" })).toBeDisabled();
+ });
+});
diff --git a/e2e/tests/manage-event.spec.ts b/e2e/tests/manage-event.spec.ts
new file mode 100644
index 00000000..bc23c91e
--- /dev/null
+++ b/e2e/tests/manage-event.spec.ts
@@ -0,0 +1,194 @@
+import { expect, test } from "@playwright/test";
+import { callMethod, createDoc, ensureTestTeam, getDoc } from "../helpers/frappe";
+
+// Runs under the shared Administrator state, whose team hosts the event seeded by
+// event.setup.ts — the one card guaranteed to carry a Manage button.
+test.describe("Event workspace", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/b/manage/events");
+ await page.getByRole("link", { name: "Manage" }).first().click();
+ });
+
+ test("opens the event on its details section", async ({ page }) => {
+ await expect(page).toHaveURL(/\/b\/manage\/events\/\d+\/details$/, { timeout: 15000 });
+ // The details page opens on the event's own values, with the section after it
+ // in the trail.
+ await expect(page.getByRole("textbox", { name: "Event title" })).not.toHaveValue("", {
+ timeout: 15000,
+ });
+ await expect(page.getByRole("banner").first()).toContainText("Details");
+ });
+
+ test("saves an edit, offering Save only once something changed", async ({ page }) => {
+ const description = page.getByRole("textbox", { name: "Short description" });
+ await expect(description).toBeVisible({ timeout: 15000 });
+
+ const save = page.getByRole("button", { name: "Save" });
+ await expect(save).toHaveCount(0);
+
+ const text = `Edited at ${Date.now()}`;
+ await description.fill(text);
+ await save.click();
+
+ await expect(page.getByText("Event saved")).toBeVisible();
+ await page.reload();
+ await expect(description).toHaveValue(text, { timeout: 15000 });
+ });
+
+ test("shows the event's public address beside its name", async ({ page }) => {
+ const field = page.getByRole("textbox", { name: "Event route" });
+ await expect(field).toBeVisible({ timeout: 15000 });
+
+ // The host is fixed text; only the part after the slash is editable.
+ const route = await field.inputValue();
+ expect(route).not.toBe("");
+
+ const open = page.getByRole("link", { name: "Open event page" });
+ await expect(open).toHaveAttribute("href", `/b/register/${route}`);
+ await expect(open).toHaveAttribute("target", "_blank");
+ await expect(page.getByRole("button", { name: "Copy" })).toBeVisible();
+ });
+
+ test("swaps the venue for a meeting link when the event turns virtual", async ({ page }) => {
+ const venue = page.getByRole("combobox", { name: "Search venues, or add one" });
+ await expect(venue).toBeVisible({ timeout: 15000 });
+
+ await page.getByRole("button", { name: "Virtual" }).click();
+
+ await expect(venue).toHaveCount(0);
+ const link = page.getByRole("textbox", { name: "Meeting link" });
+ await expect(link).toBeVisible();
+ // Nothing to copy until a link is there.
+ await expect(page.getByRole("button", { name: "Copy meeting link" })).toBeDisabled();
+
+ await page.getByRole("button", { name: "In person" }).click();
+ await expect(venue).toBeVisible();
+ });
+
+ test("swaps the sidebar for the event's own destinations", async ({ page }) => {
+ for (const label of ["Back", "Details", "Attendees", "Talks"]) {
+ await expect(page.getByRole("link", { name: label })).toBeVisible({ timeout: 15000 });
+ }
+ await expect(page.getByRole("link", { name: "My Tickets" })).toHaveCount(0);
+ });
+
+ test("moves between sections", async ({ page }) => {
+ await page.getByRole("link", { name: "Talks" }).click();
+
+ await expect(page).toHaveURL(/\/b\/manage\/events\/\d+\/talks$/);
+ await expect(page.getByText("Work in progress")).toBeVisible();
+ });
+
+ test("leaves the workspace through the back item", async ({ page }) => {
+ await page.getByRole("link", { name: "Back" }).click();
+
+ await expect(page).toHaveURL(/\/b\/manage\/events$/, { timeout: 15000 });
+ await expect(page.getByRole("heading", { name: "Events", level: 1 })).toBeVisible();
+ });
+});
+
+// The shared event has no venue, and a switch has to have one to lose, so this block
+// seeds its own.
+test.describe("Switching an event's medium", () => {
+ let eventId: string;
+
+ test.beforeEach(async ({ page, request }) => {
+ const team = await ensureTestTeam(request);
+ // Event Venue is autonamed by prompt, so the docname is the venue's own name.
+ const venue = `E2E Venue ${Date.now()}`;
+ await createDoc(request, "Event Venue", {
+ __newname: venue,
+ address: "1 Test Street",
+ team,
+ });
+ const event = await callMethod<{ name: string }>(request, "buzz.api.events.create_event", {
+ event: {
+ team,
+ title: `Medium Event ${Date.now()}`,
+ start_date: "2030-01-01",
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ venue,
+ },
+ });
+ eventId = String(event.name);
+ await page.goto(`/b/manage/events/${eventId}/details`);
+ });
+
+ test("drops the venue when the event turns virtual", async ({ page, request }) => {
+ await expect(page.getByRole("combobox", { name: "Search venues, or add one" })).toBeVisible({
+ timeout: 15000,
+ });
+
+ await page.getByRole("button", { name: "Virtual" }).click();
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByText("Event saved")).toBeVisible();
+
+ // The calendar invite and the booking page read the venue whatever the medium is,
+ // so it has to be gone from the record, not just from the form.
+ const saved = await getDoc<{ medium: string; venue: string | null }>(
+ request,
+ "Buzz Event",
+ eventId
+ );
+ expect(saved.medium).toBe("Online");
+ expect(saved.venue).toBeFalsy();
+ });
+});
+
+// An event with no route yet has to claim one, so this block seeds its own rather than
+// reusing the shared event, which already has one.
+test.describe("Claiming a route", () => {
+ let eventId: string;
+
+ test.beforeEach(async ({ page, request }) => {
+ const team = await ensureTestTeam(request);
+ // Through the app's own endpoint: it fills the category and host that a bare
+ // insert would be missing.
+ const event = await callMethod<{ name: string }>(request, "buzz.api.events.create_event", {
+ event: {
+ team,
+ title: `Routeless Event ${Date.now()}`,
+ start_date: "2030-01-01",
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ },
+ });
+ eventId = String(event.name);
+ await page.goto(`/b/manage/events/${eventId}/details`);
+ });
+
+ test("reports whether a typed route is free", async ({ page }) => {
+ const field = page.getByRole("textbox", { name: "Event route" });
+ await expect(field).toBeVisible({ timeout: 15000 });
+
+ await field.fill(`free-route-${Date.now()}`);
+ await expect(page.getByText("This route is available.")).toBeVisible();
+
+ // The shared event already answers to this one.
+ await field.fill("test-event-e2e");
+ await expect(page.getByText("This route is already taken.")).toBeVisible();
+
+ // Reserved so an event cannot shadow /b/account.
+ await field.fill("account");
+ await expect(page.getByText("reserved")).toBeVisible();
+ });
+});
+
+// A team's own events are all manageable, so those cards drop the button and become
+// the link themselves.
+test.describe("Team events card", () => {
+ test("opens the workspace on click, with no Manage button of its own", async ({ page }) => {
+ await page.goto("/b/manage/events");
+ await page.getByRole("link", { name: "Events", exact: true }).click();
+ await expect(page).toHaveURL(/\/b\/manage\/team\/events$/, { timeout: 15000 });
+
+ // Keyed on the href, not a title: which events the team owns varies by site.
+ const card = page.locator('a[href^="/b/manage/events/"]').first();
+ await expect(card).toBeVisible({ timeout: 15000 });
+ await expect(page.getByRole("link", { name: "Manage" })).toHaveCount(0);
+ await card.click();
+
+ await expect(page).toHaveURL(/\/b\/manage\/events\/\d+\/details$/);
+ });
+});