Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/app/api/update/meeting/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ async function handleScopedEdit(
endDateTime: newMeeting.endDateTime,
email: newMeeting.email,
calType: newMeeting.calType,
fellowship: newMeeting.fellowship ?? null,
modeType: newMeeting.modeType,
room: newMeeting.room,
// Always Active -- see the identical comment on `candidate` above.
Expand Down Expand Up @@ -897,7 +898,8 @@ function submitsAnchorEdits(
submitted.isRecurring !== existing.isRecurring ||
new Date(submitted.startDateTime).getTime() !== existing.startDateTime.getTime() ||
new Date(submitted.endDateTime).getTime() !== existing.endDateTime.getTime() ||
[...submitted.calType].sort().join("|") !== [...existing.calType].sort().join("|")
[...submitted.calType].sort().join("|") !== [...existing.calType].sort().join("|") ||
text(submitted.fellowship) !== text(existing.fellowship)
) {
return true;
}
Expand Down Expand Up @@ -1151,6 +1153,7 @@ async function handleLinkedScheduleCreate(
endDateTime,
email: anchor.email,
calType: anchor.calType,
fellowship: anchor.fellowship ?? null,
modeType: linkedSchedule.modeType,
room: candidate.room,
zoomRoom: candidate.zoomRoom,
Expand Down
1 change: 1 addition & 0 deletions frontend/app/api/write/meeting/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ const createMeeting = async (request: Request) => {
group: meetingData.group,
email: meetingData.email,
calType: meetingData.calType,
fellowship: meetingData.fellowship ?? null,
// Mirrors the primary schedule's status rather than being pinned Active: both
// rows are born here, together, so there is no prior suspension for this row to
// wrongly inherit -- and a family whose halves disagreed would have one schedule
Expand Down
55 changes: 33 additions & 22 deletions frontend/app/components/meeting-form/EditMeeting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const EditMeetingSidebar: React.FC<EditMeetingSidebarProps> =
description: inputDescriptionValue, setDescription: setDescriptionValue,
room: selectedRoom,
calTypes: selectedCalTypes,
fellowship, setFellowship,
zoomRoom: selectedZoomRoom, setZoomRoom: setSelectedZoomRoom,
zoomHost: selectedZoomHost, setZoomHost: setSelectedZoomHost,
isRecurring,
Expand Down Expand Up @@ -517,29 +518,39 @@ const EditMeetingSidebar: React.FC<EditMeetingSidebarProps> =
/>
}
meetingTypeDropdown={
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
className={styles.meetingTypeIcon}
style={{ marginRight: '6px', display: 'flex', alignItems: 'center' }}
>
<Icon name="group" size={28} ariaLabel="Group Icon" />
</span>
<div
data-testid="meeting-type-checkboxes"
style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: '16px' }}
>
{CAL_TYPE_OPTIONS.map(type => (
<LabeledCheckbox
key={type}
label={type}
checked={selectedCalTypes.includes(type)}
onChange={(_e) => handleCalTypeToggle(type)}
color={CAL_TYPE_COLOR}
uncheckedBg="#fff"
compact={compact}
/>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
className={styles.meetingTypeIcon}
style={{ marginRight: '6px', display: 'flex', alignItems: 'center' }}
>
<Icon name="group" size={28} ariaLabel="Group Icon" />
</span>
<div
data-testid="meeting-type-checkboxes"
style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: '16px' }}
>
{CAL_TYPE_OPTIONS.map(type => (
<LabeledCheckbox
key={type}
label={type}
checked={selectedCalTypes.includes(type)}
onChange={(_e) => handleCalTypeToggle(type)}
color={CAL_TYPE_COLOR}
uncheckedBg="#fff"
compact={compact}
/>
))}
</div>
</div>
{selectedCalTypes.includes("Other") && (
<TextField
input="Fellowship name (optional)"
value={fellowship}
onChange={setFellowship}
compact={compact}
/>
)}
</div>
}
zoomRoomDropdown={
Expand Down
55 changes: 33 additions & 22 deletions frontend/app/components/meeting-form/NewMeeting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const NewMeetingSidebar = React.forwardRef<NewMeetingSidebarHandle, NewMeetingSi
email: inputEmailValue, setEmail: setEmailValue,
description: inputDescriptionValue, setDescription: setDescriptionValue,
calTypes: selectedCalTypes,
fellowship, setFellowship,
room: selectedRoom,
zoomRoom: selectedZoomRoom, setZoomRoom: setSelectedZoomRoom,
zoomHost: selectedZoomHost, setZoomHost: setSelectedZoomHost,
Expand Down Expand Up @@ -289,29 +290,39 @@ const NewMeetingSidebar = React.forwardRef<NewMeetingSidebarHandle, NewMeetingSi
/>
}
meetingTypeDropdown={
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
className={styles.meetingTypeIcon}
style={{ marginRight: '6px', display: 'flex', alignItems: 'center' }}
>
<Icon name="group" size={28} ariaLabel="Group Icon" />
</span>
<div
data-testid="meeting-type-checkboxes"
style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: '16px' }}
>
{CAL_TYPE_OPTIONS.map(type => (
<LabeledCheckbox
key={type}
label={type}
checked={selectedCalTypes.includes(type)}
onChange={(_e) => handleCalTypeToggle(type)}
color={CAL_TYPE_COLOR}
uncheckedBg="#fff"
compact
/>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
className={styles.meetingTypeIcon}
style={{ marginRight: '6px', display: 'flex', alignItems: 'center' }}
>
<Icon name="group" size={28} ariaLabel="Group Icon" />
</span>
<div
data-testid="meeting-type-checkboxes"
style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: '16px' }}
>
{CAL_TYPE_OPTIONS.map(type => (
<LabeledCheckbox
key={type}
label={type}
checked={selectedCalTypes.includes(type)}
onChange={(_e) => handleCalTypeToggle(type)}
color={CAL_TYPE_COLOR}
uncheckedBg="#fff"
compact
/>
))}
</div>
</div>
{selectedCalTypes.includes("Other") && (
<TextField
input="Fellowship name (optional)"
value={fellowship}
onChange={setFellowship}
compact
/>
)}
</div>
}
zoomRoomDropdown={
Expand Down
17 changes: 14 additions & 3 deletions frontend/hooks/useMeetingForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ function computeDefaultTime(): { time: string; rolledToNextDay: boolean } {
// append in click order), so unchecking and rechecking a category isn't an "edit".
function snapshotFields(values: {
title: string; mode: string; date: string; time: string; email: string;
description: string; room: string; calTypes: string[]; zoomRoom: string; zoomHost: string;
description: string; room: string; calTypes: string[]; fellowship: string;
zoomRoom: string; zoomHost: string;
}): string {
return JSON.stringify({ ...values, calTypes: [...values.calTypes].sort() });
}
Expand Down Expand Up @@ -248,6 +249,10 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
: initialMeeting.calType ? [initialMeeting.calType as unknown as string] : []
: []
);
// Custom fellowship text behind the "Other" category checkbox. Kept even while "Other" is
// unchecked so re-checking restores what was typed; buildMeetingPayload nulls it out of the
// payload whenever "Other" isn't selected.
const [fellowship, setFellowship] = useState(initialMeeting?.fellowship ?? "");
// Every existing Remote meeting today has a non-null zoomRoom (the old rules required
// it), but Remote no longer collects/shows this field -- don't resubmit a stale value
// the new UI can't display or let the user clear.
Expand Down Expand Up @@ -320,7 +325,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
// rather than re-derived from initialMeeting -- a brand-new form's computed date/time
// defaults count as untouched too.
const [fieldBaseline, setFieldBaseline] = useState(() =>
snapshotFields({ title, mode, date, time, email, description, room, calTypes, zoomRoom, zoomHost })
snapshotFields({ title, mode, date, time, email, description, room, calTypes, fellowship, zoomRoom, zoomHost })
);

// Must be stable: RecurringMeeting.tsx's effect depends on this callback, and an
Expand Down Expand Up @@ -464,6 +469,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
description: "",
room: "",
calTypes: [] as string[],
fellowship: "",
zoomRoom: "",
zoomHost: "",
};
Expand All @@ -475,6 +481,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
setDescription(resetValues.description);
setRoom(resetValues.room);
setCalTypes(resetValues.calTypes);
setFellowship(resetValues.fellowship);
setZoomRoom(resetValues.zoomRoom);
setZoomHost(resetValues.zoomHost);
setIsRecurring(false);
Expand Down Expand Up @@ -638,6 +645,9 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
zoomRoom,
zoomHost: zoomHost || null,
calType: calTypes,
// Nulled whenever "Other" isn't selected, so unchecking the category can't leave a
// ghost prefix on external titles.
fellowship: calTypes.includes("Other") && fellowship.trim() ? fellowship.trim() : null,
status,
room,
isRecurring,
Expand Down Expand Up @@ -715,7 +725,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
// because the update route refuses to apply an edit to the meeting and add a linked schedule
// in one request (they're two writes) and so gates the "Add another mode" trigger on it.
const isAnchorDirty =
snapshotFields({ title, mode, date, time, email, description, room, calTypes, zoomRoom, zoomHost }) !== fieldBaseline ||
snapshotFields({ title, mode, date, time, email, description, room, calTypes, fellowship, zoomRoom, zoomHost }) !== fieldBaseline ||
isRecurrenceDirty;

// A composed-but-unsaved linked schedule is an unsaved change like any other -- without this
Expand Down Expand Up @@ -753,6 +763,7 @@ export function useMeetingForm(initialMeeting?: IMeeting, defaultContext?: Meeti
description, setDescription,
room, setRoom,
calTypes, setCalTypes,
fellowship, setFellowship,
zoomRoom, setZoomRoom,
zoomHost, setZoomHost,
isRecurring, setIsRecurring,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Custom fellowship name for meetings whose calType includes "Other"; AA/Al-Anon derive from
-- calType at title-build time, so no backfill is needed.
ALTER TABLE "Meeting" ADD COLUMN "fellowship" TEXT;
4 changes: 4 additions & 0 deletions frontend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ model Meeting {
mid String @unique
title String
calType String[]
// Custom fellowship name, entered only when calType includes "Other". AA/Al-Anon are never
// stored here -- they derive from calType when building external titles (see
// util/meetings/linkedSchedules.ts fellowshipPrefixedTitle).
fellowship String?
description String
creator String
group String
Expand Down
4 changes: 2 additions & 2 deletions frontend/services/googleCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "server-only";
import { google } from "googleapis";
import { IMeeting, IRecurrencePattern } from "../types/models";
import { getETDayBounds, convertETToUTC } from "../util/date/timeUtils";
import { buildLinkedScheduleLabel, LINKED_SCHEDULE_MODE_LABEL } from "../util/meetings/linkedSchedules";
import { buildLinkedScheduleLabel, fellowshipPrefixedTitle, LINKED_SCHEDULE_MODE_LABEL } from "../util/meetings/linkedSchedules";

export const calendarIdForCategory: Record<string, string> = {
AA: process.env.GOOGLE_CALENDAR_AA ?? "",
Expand Down Expand Up @@ -135,7 +135,7 @@ const CALENDAR_SINGLE_TITLE_SUFFIX = LINKED_SCHEDULE_MODE_LABEL;
// TODO(linked-schedules PR5): removing a linked schedule is a plain row soft-delete, which
// leaves the SURVIVOR's events on the two-schedule name until it is next written.
function buildEventTitle(meeting: IMeeting, family: IMeeting[]): string {
return buildLinkedScheduleLabel(meeting.title, meeting, family, CALENDAR_SINGLE_TITLE_SUFFIX);
return buildLinkedScheduleLabel(fellowshipPrefixedTitle(meeting), meeting, family, CALENDAR_SINGLE_TITLE_SUFFIX);
}

// family: the meeting's linked-schedule family (util/meetings/linkedSchedules.ts), for the
Expand Down
4 changes: 2 additions & 2 deletions frontend/services/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Prisma } from "@prisma/client";
import { IMeeting } from "../types/models";
import { expandOccurrences, findResourceConflicts, findFirstFreePoolHost, getPoolHostLoads, OccurrenceInput } from "../util/meetings/resourceOverlap";
import { isSharedZoomScheduleCompatible } from "../util/meetings/sharedZoomSchedule";
import { buildLinkedScheduleLabel, isZoomBearing, resolveFamilyRows } from "../util/meetings/linkedSchedules";
import { buildLinkedScheduleLabel, fellowshipPrefixedTitle, isZoomBearing, resolveFamilyRows } from "../util/meetings/linkedSchedules";
import { prisma } from "../lib/prisma";

const ZOOM_BASE_API = process.env.NEXT_PUBLIC_ZOOM_BASE_API ?? "https://api.zoom.us/v2";
Expand Down Expand Up @@ -337,7 +337,7 @@ const ZOOM_SINGLE_TOPIC_SUFFIX: Record<string, string> = {
// Meeting.zoomTopic: a null column keeps meaning "auto, recompute from the current family."
function zoomTopicFor(meeting: IMeeting, family: IMeeting[] = []): string {
if (meeting.zoomTopic) return meeting.zoomTopic;
return buildLinkedScheduleLabel(meeting.title, meeting, family, ZOOM_SINGLE_TOPIC_SUFFIX);
return buildLinkedScheduleLabel(fellowshipPrefixedTitle(meeting), meeting, family, ZOOM_SINGLE_TOPIC_SUFFIX);
}

function buildZoomMeetingBody(meeting: IMeeting, family: IMeeting[] = []) {
Expand Down
17 changes: 17 additions & 0 deletions frontend/tests/component/NewMeeting.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,20 @@ describe("NewMeetingSidebar linked schedule", () => {
expect(within(draft).getByRole("button", { name: "Saturday" })).toBeEnabled();
});
});

describe("fellowship input behind the Other category", () => {
it("appears only while Other is checked", async () => {
const ref = React.createRef<NewMeetingSidebarHandle>();
renderNewMeeting(ref);
await act(async () => {});

expect(screen.queryByPlaceholderText("Fellowship name (optional)")).toBeNull();

const checkboxes = screen.getByTestId("meeting-type-checkboxes");
fireEvent.click(within(checkboxes).getByLabelText("Other"));
expect(screen.getByPlaceholderText("Fellowship name (optional)")).toBeInTheDocument();

fireEvent.click(within(checkboxes).getByLabelText("Other"));
expect(screen.queryByPlaceholderText("Fellowship name (optional)")).toBeNull();
});
});
39 changes: 39 additions & 0 deletions frontend/tests/component/useMeetingForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,42 @@ describe("useMeetingForm linked draft against a non-weekly recurrence", () => {
expect(result.current.isScheduleConfirmed).toBe(false);
});
});

describe("useMeetingForm fellowship field", () => {
it("submits trimmed fellowship only while Other is checked", () => {
const { result } = renderHook(() => useMeetingForm(baseMeeting));
act(() => {
result.current.handleCalTypeToggle("Other");
result.current.setFellowship(" NA ");
});
expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBe("NA");

// Unchecking Other nulls the payload value even though the typed text is kept in state,
// so a ghost prefix can't linger on external titles.
act(() => result.current.handleCalTypeToggle("Other"));
expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBeNull();
expect(result.current.fellowship).toBe(" NA ");
});

it("empty fellowship submits null and stays optional (no validation error)", () => {
const { result } = renderHook(() => useMeetingForm(baseMeeting));
act(() => result.current.handleCalTypeToggle("Other"));
expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBeNull();
expect(result.current.getValidationErrors()).toEqual([]);
});

it("editing fellowship marks the form dirty; resetForm clears it", () => {
const { result } = renderHook(() => useMeetingForm(baseMeeting));
expect(result.current.isDirty).toBe(false);
act(() => result.current.setFellowship("NA"));
expect(result.current.isDirty).toBe(true);
act(() => result.current.resetForm());
expect(result.current.fellowship).toBe("");
});

it("seeds fellowship from the stored meeting", () => {
const { result } = renderHook(() => useMeetingForm({ ...baseMeeting, calType: ["Other"], fellowship: "NA" }));
expect(result.current.fellowship).toBe("NA");
expect(result.current.buildMeetingPayload("m-1", "Active")?.fellowship).toBe("NA");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ test("editScope 'this' excludes the occurrence on the parent and creates a detac
const { meeting } = await seedWeeklySeries({ googleCalendarEventIds: { AA: "parent-event-aa" } });
const occurrenceDate = occurrence(2).start;

const response = await putMeeting(scopedPayload(meeting.mid, "this", occurrenceDate, { title: "Just This Week" }));
const response = await putMeeting(scopedPayload(meeting.mid, "this", occurrenceDate, { title: "Just This Week", calType: ["AA", "Other"], fellowship: "NA" }));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.newMid).toBeTruthy();
Expand All @@ -256,6 +256,7 @@ test("editScope 'this' excludes the occurrence on the parent and creates a detac
expect(created?.isRecurring).toBe(false);
expect(created?.splitFromMid).toBe(meeting.mid);
expect(created?.title).toBe("Just This Week");
expect(created?.fellowship).toBe("NA");
expect(created?.zid).toBe(meeting.zid);
expect(created?.zoomHost).toBe(meeting.zoomHost);
expect(created?.zoomManaged).toBe(meeting.zoomManaged);
Expand Down
14 changes: 14 additions & 0 deletions frontend/tests/integration/write-meeting-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,17 @@ test("a missing access token persists an error status instead of leaving googleS
expect(afterSync?.googleSyncError).toBeTruthy();
expect(mockedCreateCalendarEvent).not.toHaveBeenCalled();
});

test("fellowship is persisted through the zod parse and Prisma create", async () => {
mockedCreateCalendarEvent.mockResolvedValue({ id: "fake-event-id", error: null });
const payload = buildMeetingPayload({ calType: ["AA", "Other"], fellowship: "NA", room: "Fellowship Room" });
const response = await POST(new Request("http://localhost/api/write/meeting", {
method: "POST",
body: JSON.stringify(payload),
}));
expect(response.status).toBe(201);

const prisma = getTestPrismaClient();
const stored = await prisma.meeting.findUnique({ where: { mid: payload.mid } });
expect(stored?.fellowship).toBe("NA");
});
Loading
Loading