mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user