From 3dd3859ebfa60a72299c41b489f1e403ec72c8c2 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 14 Oct 2025 13:47:02 -0300 Subject: [PATCH] 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:** Screenshot 2025-10-09 at 14 31 53 --- site/src/api/api.ts | 16 ++ site/src/components/Dialog/Dialog.tsx | 2 +- .../TaskFeedbackDialog.stories.tsx | 120 ++++++++++++++ .../TaskFeedbackDialog/TaskFeedbackDialog.tsx | 147 ++++++++++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx create mode 100644 site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f5b47ed824..a5c9148b01 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { await this.axios.delete(`/api/experimental/tasks/${user}/${id}`); }; + + createTaskFeedback = async ( + _taskId: string, + _req: CreateTaskFeedbackRequest, + ) => { + return new Promise((res) => { + setTimeout(() => res(), 500); + }); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 13484f1840..61a6ee9f8d 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -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, diff --git a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx new file mode 100644 index 0000000000..d1e8e4ddbb --- /dev/null +++ b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx @@ -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 = { + title: "modules/tasks/TaskFeedbackDialog", + component: TaskFeedbackDialog, + args: { + taskId: MockTask.id, + open: true, + }, +}; + +export default meta; +type Story = StoryObj; + +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); + }); + }, +}; diff --git a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx new file mode 100644 index 0000000000..ddb05fec43 --- /dev/null +++ b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx @@ -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 = ({ + taskId, + ...dialogProps +}) => { + const { + mutate: createFeedback, + error, + isPending, + } = useMutation({ + mutationFn: (req: CreateTaskFeedbackRequest) => + API.experimental.createTaskFeedback(taskId, req), + onSuccess: () => { + displaySuccess("Feedback submitted successfully"); + }, + }); + + const formik = useFormik({ + initialValues: { + rate: null, + comment: "", + }, + onSubmit: (values) => { + if (values.rate !== null) { + createFeedback({ + rate: values.rate, + comment: values.comment, + }); + } + }, + }); + + const isRateSelected = Boolean(formik.values.rate); + + return ( + + + + Task feedback + + Your feedback is important to us. Please rate your experience with + this task. + + + +
+ {error && } + +
+ Rate your experience + + I achieved my goal + + + + It sort of worked, but struggled a lot + + + + It was a flop + +
+ + +