Skip to content

Commit

Permalink
[INT-201] #CheckWithTech enhancements (#168)
Browse files Browse the repository at this point in the history
* Check With Tech via Slack

* UI + AdamRMS integration

* Update features/calendar/events.ts

* Fix build

* Unbork link syntax

* Fix AdamRMS comment formatting

* Make the slack icon clearer

* Tidy up modal submit button

* Autosize text area fields

* Update .env.example and elaborate on slack setup docs

* Use same flow for tech help + tweak slack verbiage

* Permissions check and hide for AdamRMS linked

* Update docs/setup_slack.md

* Send ephemeral messages to user instead of spamming channel

* Remove SLACK_TECH_HELP_CHANNEL from .env.example

* Spacing on confirmed banner

---------

Co-authored-by: Mia Moir <[email protected]>
  • Loading branch information
markspolakovs and archessmn authored Oct 9, 2024
1 parent d661f20 commit d963d80
Show file tree
Hide file tree
Showing 29 changed files with 1,178 additions and 144 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ SLACK_SIGNING_SECRET=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_TEAM_ID= # Should be set only if the slack integration is used across multiple workspaces
SLACK_CHECK_WITH_TECH_CHANNEL=
SLACK_USER_FEEDBACK_CHANNEL=
SENTRY_PROJECT_ID=
129 changes: 125 additions & 4 deletions app/(authenticated)/calendar/[eventID]/CheckWithTech.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
"use client";

import { Alert, Button, ButtonGroup, Modal, Textarea } from "@mantine/core";
import {
Alert,
Button,
ButtonGroup,
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalTitle,
Space,
Stack,
Textarea,
} from "@mantine/core";
import { Suspense, cache, use, useState, useTransition } from "react";
import { TbBrandSlack, TbTool } from "react-icons/tb";
import { doCheckWithTech, equipmentListTemplates } from "./actions";
import { TbTool } from "react-icons/tb";
import {
actionCheckWithTech,
doCheckWithTech,
equipmentListTemplates,
} from "./actions";
import { notifications } from "@mantine/notifications";
import { CheckWithTechType } from "@/features/calendar";
import { getUserName } from "@/components/UserHelpers";
import { useModals } from "@mantine/modals";
import Form from "@/components/Form";
import { CheckWithTechActionSchema } from "./schema";
import { HiddenField, TextAreaField } from "@/components/FormFields";
import SlackIcon from "@/components/icons/SlackIcon";

const _getEquipmentListTemplates = cache(equipmentListTemplates);

Expand Down Expand Up @@ -118,7 +141,8 @@ function PostMessage(props: {
}
})
}
leftSection={<TbBrandSlack size={14} />}
variant="light"
leftSection={<SlackIcon height={14} width={14} />}
>
Send
</Button>
Expand Down Expand Up @@ -189,3 +213,100 @@ export function CheckWithTechPromptContents(props: { eventID: number }) {
</>
);
}

export function CheckWithTechAdminBanner({ cwt }: { cwt: CheckWithTechType }) {
const [modalOpen, setModalOpen] = useState<
"approve" | "note" | "decline" | null
>(null);

return (
<>
<Alert
variant="light"
color="blue"
title="#CheckWithTech"
icon={<TbTool />}
>
<strong>
#CheckWithTech request from {getUserName(cwt.submitted_by_user)}
</strong>
<p>{cwt.request}</p>
<Stack>
{cwt.unsure && (
<strong>
Note: {cwt.submitted_by_user.first_name} was not sure of what they
need - please get in touch and amend as needed
</strong>
)}
{cwt.notes.length > 0 && <p>Notes: {cwt.notes}</p>}
{cwt.status !== "Requested" && (
<strong>
{cwt.status}
{cwt.confirmed_by_user &&
" by " + getUserName(cwt.confirmed_by_user)}
</strong>
)}
</Stack>
{cwt.status === "Requested" && (
<ButtonGroup>
<Button onClick={() => setModalOpen("approve")} color="green">
Approve
</Button>
<Button onClick={() => setModalOpen("note")}>Leave Note</Button>
<Button onClick={() => setModalOpen("decline")} color="red">
Decline
</Button>
</ButtonGroup>
)}
</Alert>
<Space h={"md"} />
<Modal opened={modalOpen !== null} onClose={() => setModalOpen(null)}>
<ModalHeader>
<ModalTitle>
{modalOpen === "approve"
? "Approve"
: modalOpen === "note"
? "Leave Note"
: "Decline"}
</ModalTitle>
</ModalHeader>
<ModalBody>
{modalOpen !== null && (
<Form
action={actionCheckWithTech}
schema={CheckWithTechActionSchema}
onSuccess={() => setModalOpen(null)}
initialValues={{
action: modalOpen,
cwtID: cwt.cwt_id,
eventID: cwt.event_id,
request: cwt.request,
note: cwt.notes,
}}
submitLabel={
modalOpen === "approve"
? "Approve"
: modalOpen === "note"
? "Leave Note"
: "Decline"
}
submitColor={
modalOpen === "approve"
? "green"
: modalOpen === "note"
? "blue"
: "red"
}
>
<HiddenField name="cwtID" value={cwt.cwt_id.toString(10)} />
{modalOpen === "approve" && (
<TextAreaField name="request" label="Request" autosize />
)}
<TextAreaField name="note" label="Notes" autosize />
</Form>
)}
</ModalBody>
</Modal>
</>
);
}
48 changes: 42 additions & 6 deletions app/(authenticated)/calendar/[eventID]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import * as Calendar from "@/features/calendar";
import { EventType, hasRSVP } from "@/features/calendar/types";
import { canManage } from "@/features/calendar/permissions";
import { zodErrorResponse } from "@/components/FormServerHelpers";
import { EditEventSchema } from "@/app/(authenticated)/calendar/[eventID]/schema";
import {
CheckWithTechActionSchema,
EditEventSchema,
} from "@/app/(authenticated)/calendar/[eventID]/schema";
import { FormResponse } from "@/components/Form";
import { updateEventAttendeeStatus } from "@/features/calendar/events";
import invariant from "@/lib/invariant";
Expand Down Expand Up @@ -309,11 +312,13 @@ export const doCheckWithTech = wrapServerAction(
};
}

if (isConfident) {
await Calendar.postCheckWithTech(eventID, memo);
} else {
await Calendar.postTechHelpRequest(eventID, memo);
}
await Calendar.postCheckWithTech(
eventID,
memo,
isConfident ? "check" : "help",
);

revalidatePath(`/calendar/${event.event_id}`);

return { ok: true };
},
Expand All @@ -325,3 +330,34 @@ export const equipmentListTemplates = wrapServerAction(
return await Calendar.getEquipmentListTemplates();
},
);

export const actionCheckWithTech = wrapServerAction(
"actionCheckWithTech",
async function actionCheckWithTech(dataRaw: unknown): Promise<FormResponse> {
const data = CheckWithTechActionSchema.safeParse(dataRaw);
if (!data.success) {
return zodErrorResponse(data.error);
}
const { cwtID, action, note, request, eventID } = data.data;
switch (action) {
case "approve":
if (!request) {
return { ok: false, errors: { request: "No request provided" } };
}
await Calendar.approveCheckWithTech(cwtID, request, note);
break;
case "note":
if (!note) {
return { ok: false, errors: { note: "No note provided" } };
}
await Calendar.addNoteToCheckWithTech(cwtID, note);
break;
case "decline":
await Calendar.declineCheckWithTech(cwtID, note);
break;
}

revalidatePath(`/calendar/${eventID}`);
return { ok: true };
},
);
65 changes: 54 additions & 11 deletions app/(authenticated)/calendar/[eventID]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation";
import invariant from "@/lib/invariant";
import { getUserName } from "@/components/UserHelpers";
import { mustGetCurrentUser, UserType } from "@/lib/auth/server";
import { hasPermission, mustGetCurrentUser, UserType } from "@/lib/auth/server";
import { CurrentUserAttendeeRow } from "@/app/(authenticated)/calendar/[eventID]/AttendeeStatus";
import { AttendStatusLabels } from "@/features/calendar/statuses";
import { SignupSheetsView } from "@/app/(authenticated)/calendar/[eventID]/SignupSheet";
Expand All @@ -13,22 +13,26 @@ import {
canManage,
canManageAnySignupSheet,
getAllCrewPositions,
getLatestRequest,
} from "@/features/calendar";
import {
CrewPositionsProvider,
MembersProvider,
} from "@/components/FormFieldPreloadedData";
import { getAllUsers } from "@/features/people";
import { EventActionsUI } from "./EventActionsUI";
import { Alert, Space, Text } from "@mantine/core";
import { TbInfoCircle, TbAlertTriangle } from "react-icons/tb";
import { Alert, Button, ButtonGroup, Space, Text } from "@mantine/core";
import { TbInfoCircle, TbAlertTriangle, TbTool } from "react-icons/tb";
import slackApiConnection, {
isSlackEnabled,
} from "@/lib/slack/slackApiConnection";
import { Suspense } from "react";
import SlackChannelName from "@/components/slack/SlackChannelName";
import SlackLoginButton from "@/components/slack/SlackLoginButton";
import { CheckWithTechPromptContents } from "./CheckWithTech";
import {
CheckWithTechAdminBanner,
CheckWithTechPromptContents,
} from "./CheckWithTech";
import { C } from "@fullcalendar/core/internal-common";
import dayjs from "dayjs";
import { PageInfo } from "@/components/PageInfo";
Expand Down Expand Up @@ -99,10 +103,6 @@ async function CheckWithTechPrompt({
if (!canManageAnySignupSheet(event, me)) {
return null;
}
if (event.adam_rms_project_id || event.check_with_tech_status) {
// assume already checked
return null;
}
if (dayjs(event.start_date).isBefore(new Date())) {
// no point checking something in the past
return null;
Expand All @@ -111,13 +111,56 @@ async function CheckWithTechPrompt({
// signup sheets take priority
return null;
}
const slack = await slackApiConnection();
if (!slack) {
if (!isSlackEnabled) {
return null;
}
const cwt = await getLatestRequest(event.event_id);

if (cwt && (await hasPermission("CheckWithTech.Admin"))) {
return <CheckWithTechAdminBanner cwt={cwt} />;
}

if (event.adam_rms_project_id !== null) {
// Assume already checked
return null;
}

if (!(await hasPermission("CheckWithTech.Submit"))) {
return null;
}

let contents;
if (!cwt) {
contents = <CheckWithTechPromptContents eventID={event.event_id} />;
} else {
switch (cwt.status) {
case "Rejected":
// Don't show rejected CWTs, just prompt to create a new one
contents = <CheckWithTechPromptContents eventID={event.event_id} />;
break;
case "Requested":
contents = (
<Alert
variant="light"
color="blue"
title="#CheckWithTech"
icon={<TbTool />}
>
Your #CheckWithTech has been submitted to the tech team. Keep an eye
on Slack in case they need any further details!
</Alert>
);
break;
case "Confirmed":
contents = null; // Don't show anything if it's already confirmed, reduce banner fatigue
break;
default:
invariant(false, `unexpected CWT status: ${cwt.status}`);
}
}
return (
<>
<CheckWithTechPromptContents eventID={event.event_id} />
{contents}
<Space h={"lg"} />
</>
);
Expand Down
8 changes: 8 additions & 0 deletions app/(authenticated)/calendar/[eventID]/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,11 @@ export const SignupSheetSchema = z.object({
unlock_date: z.coerce.date().nullable(),
crews: z.array(CrewSchema),
});

export const CheckWithTechActionSchema = z.object({
cwtID: z.coerce.number(),
eventID: z.coerce.number(),
action: z.enum(["approve", "note", "decline"]),
note: z.string().optional(),
request: z.string().optional(),
});
4 changes: 3 additions & 1 deletion components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useCallback, useState, useTransition } from "react";
import classNames from "classnames";
import { FieldPath } from "react-hook-form/dist/types/path";
import { DebugOnly } from "@/components/DebugMode";
import { Button } from "@mantine/core";
import { Button, DefaultMantineColor } from "@mantine/core";

export interface FormErrorResponse<Fields extends FieldValues = any> {
ok: false;
Expand Down Expand Up @@ -44,6 +44,7 @@ export default function Form<
children: React.ReactNode;
className?: string;
submitLabel?: string;
submitColor?: DefaultMantineColor;
onSuccess?: (res: SuccessfulResponse) => void;
}) {
const form = useForm<z.infer<Schema>>({
Expand Down Expand Up @@ -106,6 +107,7 @@ export default function Form<
type="submit"
disabled={!form.formState.isValid}
loading={isSubmitting}
color={props.submitColor}
>
{props.submitLabel ?? "Create"}
</Button>
Expand Down
9 changes: 9 additions & 0 deletions docs/setup_slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ Sroll down slightly to `User Token Scopes` and add `team.read`.

You can now scroll up to the top of the page and click `Install to Workspace`. Click `Allow` when prompted. This should take you back to the **OAuth & Permissions** page. Towards the top of the page you should see `Bot User OAuth Token`. Copy the value of this over to `SLACK_BOT_TOKEN`.

## Setting up channels

This app uses a number of slack channels for integration with check-with-tech responses, feedback, and others. These three channels are:

- `SLACK_CHECK_WITH_TECH_CHANNEL` - Used for check-with-tech requests
- `SLACK_USER_FEEDBACK_CHANNEL` - Used for user feedback via the feedback form at the bottom of each page

Once you have channels you would like to use for this purpose, get the channel ID by copying the link of the channel and taking the last bit of the link that looks something like `C07J1G4L0BA` and set the variables accordingly.

## Enable Integration

Once these five variables are set, enable the integration by setting `SLACK_ENABLED` to `true`. If you start up your instance you should now be able to link your Slack account from your user profile and start assigning channels to events. If you have any questions ask Mia because she's probably made it a bit complicated.
1 change: 1 addition & 0 deletions features/calendar/adamRMS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export async function addProjectToAdamRMS(
projectId,
`This project is linked to the Calendar event "${event.name}" (${env.PUBLIC_URL}/calendar/${event.event_id}).`,
);
return projectId;
}

export async function getAdamRMSLinkCandidates() {
Expand Down
Loading

0 comments on commit d963d80

Please sign in to comment.