chore: add task feedback dialog component (#20252)

Related to https://github.com/coder/coder/issues/20214

This PR aims to add only the FE component for capturing user feedback
from tasks. Once the BE work is completed (in a separate PR), this
component will be triggered after a task is deleted.

The goal is to develop this feature in parallel.

**Screenshot:**
<img width="1206" height="727" alt="Screenshot 2025-10-09 at 14 31 53"
src="https://github.com/user-attachments/assets/1f92026c-8f05-4535-bbd6-85c4b107c037"
/>
This commit is contained in:
Bruno Quaresma
2025-10-14 13:47:02 -03:00
committed by GitHub
parent 9f229370e7
commit 3dd3859ebf
4 changed files with 284 additions and 1 deletions
+16
View File
@@ -2669,6 +2669,13 @@ class ApiMethods {
//
// All methods must be defined with arrow function syntax. See the docstring
// above the ApiMethods class for a full explanation.
export type TaskFeedbackRating = "good" | "okay" | "bad";
export type CreateTaskFeedbackRequest = {
rate: TaskFeedbackRating;
comment?: string;
};
class ExperimentalApiMethods {
constructor(protected readonly axios: AxiosInstance) {}
@@ -2732,6 +2739,15 @@ class ExperimentalApiMethods {
deleteTask = async (user: string, id: string): Promise<void> => {
await this.axios.delete(`/api/experimental/tasks/${user}/${id}`);
};
createTaskFeedback = async (
_taskId: string,
_req: CreateTaskFeedbackRequest,
) => {
return new Promise<void>((res) => {
setTimeout(() => res(), 500);
});
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+1 -1
View File
@@ -19,7 +19,7 @@ export const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const _DialogClose = DialogPrimitive.Close;
export const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
@@ -0,0 +1,120 @@
import { MockTask, mockApiError } from "testHelpers/entities";
import { withGlobalSnackbar } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { TaskFeedbackDialog } from "./TaskFeedbackDialog";
const meta: Meta<typeof TaskFeedbackDialog> = {
title: "modules/tasks/TaskFeedbackDialog",
component: TaskFeedbackDialog,
args: {
taskId: MockTask.id,
open: true,
},
};
export default meta;
type Story = StoryObj<typeof TaskFeedbackDialog>;
export const Idle: Story = {};
export const Submitting: Story = {
beforeEach: async () => {
spyOn(API.experimental, "createTaskFeedback").mockImplementation(() => {
return new Promise(() => {});
});
},
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
step("fill and submit the form", async () => {
const regularOption = body.getByLabelText(
"It sort of worked, but struggled a lot",
);
userEvent.click(regularOption);
const commentTextarea = body.getByRole("textbox", {
name: "Additional comments",
});
await userEvent.type(commentTextarea, "This is my comment");
const submitButton = body.getByRole("button", {
name: "Submit Feedback",
});
await userEvent.click(submitButton);
});
},
};
export const Success: Story = {
args: {
open: true,
},
decorators: [withGlobalSnackbar],
beforeEach: async () => {
spyOn(API.experimental, "createTaskFeedback").mockResolvedValue();
},
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
step("fill and submit the form", async () => {
const regularOption = body.getByLabelText(
"It sort of worked, but struggled a lot",
);
userEvent.click(regularOption);
const commentTextarea = body.getByRole("textbox", {
name: "Additional comments",
});
await userEvent.type(commentTextarea, "This is my comment");
const submitButton = body.getByRole("button", {
name: "Submit Feedback",
});
await userEvent.click(submitButton);
});
step("submitted successfully", async () => {
await body.findByText("Feedback submitted successfully");
expect(API.experimental.createTaskFeedback).toHaveBeenCalledWith(
MockTask.id,
{
rate: "regular",
comment: "This is my comment",
},
);
});
},
};
export const Failure: Story = {
beforeEach: async () => {
spyOn(API.experimental, "createTaskFeedback").mockRejectedValue(
mockApiError({
message: "Failed to submit feedback",
detail: "Server is down",
}),
);
},
play: async ({ canvasElement, step }) => {
const body = within(canvasElement.ownerDocument.body);
step("fill and submit the form", async () => {
const regularOption = body.getByLabelText(
"It sort of worked, but struggled a lot",
);
userEvent.click(regularOption);
const commentTextarea = body.getByRole("textbox", {
name: "Additional comments",
});
await userEvent.type(commentTextarea, "This is my comment");
const submitButton = body.getByRole("button", {
name: "Submit Feedback",
});
await userEvent.click(submitButton);
});
},
};
@@ -0,0 +1,147 @@
import {
API,
type CreateTaskFeedbackRequest,
type TaskFeedbackRating,
} from "api/api";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import type { DialogProps } from "components/Dialogs/Dialog";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Spinner } from "components/Spinner/Spinner";
import { Textarea } from "components/Textarea/Textarea";
import { useFormik } from "formik";
import { FrownIcon, MehIcon, SmileIcon } from "lucide-react";
import type { FC, HTMLProps, ReactNode } from "react";
import { useMutation } from "react-query";
type TaskFeedbackFormValues = {
rate: TaskFeedbackRating | null;
comment: string;
};
type TaskFeedbackDialogProps = DialogProps & {
taskId: string;
};
export const TaskFeedbackDialog: FC<TaskFeedbackDialogProps> = ({
taskId,
...dialogProps
}) => {
const {
mutate: createFeedback,
error,
isPending,
} = useMutation({
mutationFn: (req: CreateTaskFeedbackRequest) =>
API.experimental.createTaskFeedback(taskId, req),
onSuccess: () => {
displaySuccess("Feedback submitted successfully");
},
});
const formik = useFormik<TaskFeedbackFormValues>({
initialValues: {
rate: null,
comment: "",
},
onSubmit: (values) => {
if (values.rate !== null) {
createFeedback({
rate: values.rate,
comment: values.comment,
});
}
},
});
const isRateSelected = Boolean(formik.values.rate);
return (
<Dialog {...dialogProps}>
<DialogContent>
<DialogHeader>
<DialogTitle>Task feedback</DialogTitle>
<DialogDescription>
Your feedback is important to us. Please rate your experience with
this task.
</DialogDescription>
</DialogHeader>
<form
id="feedback-form"
onSubmit={formik.handleSubmit}
className="flex flex-col gap-4"
>
{error && <ErrorAlert error={error} />}
<fieldset className="flex flex-col gap-1">
<legend className="sr-only">Rate your experience</legend>
<RateOption {...formik.getFieldProps("rate")} value="good">
<SmileIcon />I achieved my goal
</RateOption>
<RateOption {...formik.getFieldProps("rate")} value="okay">
<MehIcon />
It sort of worked, but struggled a lot
</RateOption>
<RateOption {...formik.getFieldProps("rate")} value="bad">
<FrownIcon />
It was a flop
</RateOption>
</fieldset>
<label className="sr-only" htmlFor="comment">
Additional comments
</label>
<Textarea
id="comment"
placeholder="Wanna say something else?..."
className="h-32 resize-none"
{...formik.getFieldProps("comment")}
/>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Close</Button>
</DialogClose>
<Button
type="submit"
form="feedback-form"
disabled={!isRateSelected || isPending}
>
<Spinner loading={isPending} />
Submit Feedback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
type RateOptionProps = HTMLProps<HTMLInputElement> & {
children: ReactNode;
};
const RateOption: FC<RateOptionProps> = ({ children, ...inputProps }) => {
return (
<label
className={`
cursor-pointer border border-border border-solid hover:bg-surface-secondary
px-4 py-3 rounded text-sm has-[:checked]:bg-surface-quaternary
flex items-center gap-3 [&_svg]:size-4
`}
>
<input className="hidden" type="radio" {...inputProps} />
{children}
</label>
);
};