diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 172d63fd3f..efdfb26c1f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4360,8 +4360,8 @@ func (api *API) getChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) }) } -// Upper bound for the auto-archive window. Update the frontend -// constant if a settings UI is added. +// Upper bound for the auto-archive window. Keep in sync with +// the validation schema in site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx. const autoArchiveDaysMaximum = 3650 // ~10 years // putChatAutoArchiveDays updates the deployment-wide auto-archive diff --git a/site/src/api/api.ts b/site/src/api/api.ts index dd35d9a10f..ace3836585 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3379,6 +3379,24 @@ class ExperimentalApiMethods { await this.axios.put("/api/experimental/chats/config/retention-days", req); }; + getChatAutoArchiveDays = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/auto-archive-days", + ); + return response.data; + }; + + updateChatAutoArchiveDays = async ( + req: TypesGen.UpdateChatAutoArchiveDaysRequest, + ): Promise => { + await this.axios.put( + "/api/experimental/chats/config/auto-archive-days", + req, + ); + }; + updateChatTemplateAllowlist = async ( req: TypesGen.ChatTemplateAllowlist, ): Promise => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 5ab48bfbce..711d28a89a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1351,6 +1351,22 @@ export const updateChatRetentionDays = (queryClient: QueryClient) => ({ }, }); +const chatAutoArchiveDaysKey = ["chat-auto-archive-days"] as const; + +export const chatAutoArchiveDays = () => ({ + queryKey: chatAutoArchiveDaysKey, + queryFn: () => API.experimental.getChatAutoArchiveDays(), +}); + +export const updateChatAutoArchiveDays = (queryClient: QueryClient) => ({ + mutationFn: API.experimental.updateChatAutoArchiveDays, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatAutoArchiveDaysKey, + }); + }, +}); + const chatTemplateAllowlistKey = ["chat-template-allowlist"] as const; export const chatTemplateAllowlist = () => ({ diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx index 0dd07eef45..6bac0827f3 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx @@ -1,8 +1,10 @@ import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { + chatAutoArchiveDays, chatRetentionDays, chatWorkspaceTTL, + updateChatAutoArchiveDays, updateChatRetentionDays, updateChatWorkspaceTTL, } from "#/api/queries/chats"; @@ -21,12 +23,19 @@ const AgentSettingsLifecyclePage: FC = () => { ...chatRetentionDays(), enabled: permissions.editDeploymentConfig, }); + const autoArchiveDaysQuery = useQuery({ + ...chatAutoArchiveDays(), + enabled: permissions.editDeploymentConfig, + }); const saveWorkspaceTTLMutation = useMutation( updateChatWorkspaceTTL(queryClient), ); const saveRetentionDaysMutation = useMutation( updateChatRetentionDays(queryClient), ); + const saveAutoArchiveDaysMutation = useMutation( + updateChatAutoArchiveDays(queryClient), + ); return ( @@ -43,6 +52,12 @@ const AgentSettingsLifecyclePage: FC = () => { onSaveRetentionDays={saveRetentionDaysMutation.mutate} isSavingRetentionDays={saveRetentionDaysMutation.isPending} isSaveRetentionDaysError={saveRetentionDaysMutation.isError} + autoArchiveDaysData={autoArchiveDaysQuery.data} + isAutoArchiveDaysLoading={autoArchiveDaysQuery.isLoading} + isAutoArchiveDaysLoadError={autoArchiveDaysQuery.isError} + onSaveAutoArchiveDays={saveAutoArchiveDaysMutation.mutate} + isSavingAutoArchiveDays={saveAutoArchiveDaysMutation.isPending} + isSaveAutoArchiveDaysError={saveAutoArchiveDaysMutation.isError} /> ); diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx index 6106ec65b4..da443368a2 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx @@ -18,6 +18,12 @@ const baseArgs: AgentSettingsLifecyclePageViewProps = { onSaveRetentionDays: fn(), isSavingRetentionDays: false, isSaveRetentionDaysError: false, + autoArchiveDaysData: { auto_archive_days: 0 }, + isAutoArchiveDaysLoading: false, + isAutoArchiveDaysLoadError: false, + onSaveAutoArchiveDays: fn(), + isSavingAutoArchiveDays: false, + isSaveAutoArchiveDaysError: false, }; const meta = { @@ -233,6 +239,231 @@ export const DefaultAutostopToggleOffFailure: Story = { }, }; +// --- Auto-Archive Stories --- + +export const AutoArchiveDefault: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable auto-archive", + }); + expect(toggle).not.toBeChecked(); + expect(canvas.queryByLabelText("Auto-archive period in days")).toBeNull(); + }, +}; + +export const AutoArchiveEnabled: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable auto-archive", + }); + expect(toggle).toBeChecked(); + + const input = await canvas.findByLabelText("Auto-archive period in days"); + expect(input).toHaveValue(90); + }, +}; + +export const AutoArchiveSaveDisabled: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText("Auto-archive period in days"); + const archiveForm = input.closest("form"); + if (!(archiveForm instanceof HTMLFormElement)) { + throw new Error("Expected auto-archive input to live inside a form."); + } + expect( + within(archiveForm).queryByRole("button", { name: "Save" }), + ).toBeNull(); + }, +}; + +export const AutoArchiveToggleOnSavesDefault: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 0 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable auto-archive", + }); + expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + + await waitFor(() => { + expect(args.onSaveAutoArchiveDays).toHaveBeenNthCalledWith( + 1, + { auto_archive_days: 90 }, + expect.anything(), + ); + }); + + const archiveForm = toggle.closest("form"); + if (!(archiveForm instanceof HTMLFormElement)) { + throw new Error("Expected auto-archive toggle to live inside a form."); + } + + const input = await within(archiveForm).findByLabelText( + "Auto-archive period in days", + ); + expect(input).toHaveValue(90); + }, +}; + +export const AutoArchiveToggleOffSavesDisabled: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable auto-archive", + }); + expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + await waitFor(() => { + expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith( + { auto_archive_days: 0 }, + expect.anything(), + ); + }); + }, +}; + +export const AutoArchiveEditDaysAndSave: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText("Auto-archive period in days"); + const archiveForm = input.closest("form"); + if (!(archiveForm instanceof HTMLFormElement)) { + throw new Error("Expected auto-archive input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "120"); + + const saveButton = within(archiveForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(args.onSaveAutoArchiveDays).toHaveBeenCalledWith( + { auto_archive_days: 120 }, + expect.anything(), + ); + }); + }, +}; + +export const AutoArchiveExceedsMax: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText("Auto-archive period in days"); + const archiveForm = input.closest("form"); + if (!(archiveForm instanceof HTMLFormElement)) { + throw new Error("Expected auto-archive input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "9999"); + + const saveButton = within(archiveForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(input).toBeInvalid(); + expect(saveButton).toBeDisabled(); + }); + await userEvent.tab(); + await waitFor(() => { + expect( + canvas.getByText(/must not exceed 3650 days/i), + ).toBeInTheDocument(); + }); + }, +}; + +export const AutoArchiveBelowMin: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText("Auto-archive period in days"); + const archiveForm = input.closest("form"); + if (!(archiveForm instanceof HTMLFormElement)) { + throw new Error("Expected auto-archive input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "0"); + + const saveButton = within(archiveForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(input).toBeInvalid(); + expect(saveButton).toBeDisabled(); + }); + await userEvent.tab(); + await waitFor(() => { + expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument(); + }); + }, +}; + +export const AutoArchiveSaveError: Story = { + args: { + autoArchiveDaysData: { auto_archive_days: 90 }, + isSaveAutoArchiveDaysError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByText("Failed to save auto-archive setting."), + ).toBeInTheDocument(); + }, +}; + +export const AutoArchiveLoadError: Story = { + args: { + autoArchiveDaysData: undefined, + isAutoArchiveDaysLoadError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable auto-archive", + }); + expect(toggle).not.toBeChecked(); + expect(canvas.queryByLabelText("Auto-archive period in days")).toBeNull(); + expect( + await canvas.findByText("Failed to load auto-archive setting."), + ).toBeInTheDocument(); + }, +}; + +// --- Retention Stories --- + export const RetentionToggleOnSavesDefault: Story = { args: { retentionDaysData: { retention_days: 0 }, @@ -352,5 +583,44 @@ export const RetentionExceedsMax: Story = { expect(retentionInput).toBeInvalid(); expect(saveButton).toBeDisabled(); }); + + await userEvent.tab(); + await waitFor(() => { + expect( + canvas.getByText(/must not exceed 3650 days/i), + ).toBeInTheDocument(); + }); + }, +}; + +export const RetentionBelowMin: Story = { + args: { + retentionDaysData: { retention_days: 30 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const retentionInput = await canvas.findByLabelText( + "Conversation retention period in days", + ); + const retentionForm = retentionInput.closest("form"); + if (!(retentionForm instanceof HTMLFormElement)) { + throw new Error("Expected retention period input to live inside a form."); + } + + await userEvent.clear(retentionInput); + await userEvent.type(retentionInput, "0"); + + const saveButton = within(retentionForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(retentionInput).toBeInvalid(); + expect(saveButton).toBeDisabled(); + }); + + await userEvent.tab(); + await waitFor(() => { + expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument(); + }); }, }; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx index 21ea43fb70..dce4972294 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import type { UseMutateFunction } from "react-query"; import type * as TypesGen from "#/api/typesGenerated"; +import { AutoArchiveSettings } from "./components/AutoArchiveSettings"; import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings"; import { SectionHeader } from "./components/SectionHeader"; import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings"; @@ -28,6 +29,17 @@ export interface AgentSettingsLifecyclePageViewProps { >; isSavingRetentionDays: boolean; isSaveRetentionDaysError: boolean; + autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined; + isAutoArchiveDaysLoading: boolean; + isAutoArchiveDaysLoadError: boolean; + onSaveAutoArchiveDays: UseMutateFunction< + void, + Error, + TypesGen.UpdateChatAutoArchiveDaysRequest, + unknown + >; + isSavingAutoArchiveDays: boolean; + isSaveAutoArchiveDaysError: boolean; } export const AgentSettingsLifecyclePageView: FC< @@ -45,6 +57,12 @@ export const AgentSettingsLifecyclePageView: FC< onSaveRetentionDays, isSavingRetentionDays, isSaveRetentionDaysError, + autoArchiveDaysData, + isAutoArchiveDaysLoading, + isAutoArchiveDaysLoadError, + onSaveAutoArchiveDays, + isSavingAutoArchiveDays, + isSaveAutoArchiveDaysError, }) => { return (
@@ -60,6 +78,14 @@ export const AgentSettingsLifecyclePageView: FC< isSavingWorkspaceTTL={isSavingWorkspaceTTL} isSaveWorkspaceTTLError={isSaveWorkspaceTTLError} /> + void; + onError?: () => void; +} + +interface AutoArchiveSettingsProps { + autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined; + isAutoArchiveDaysLoading: boolean; + isAutoArchiveDaysLoadError: boolean; + onSaveAutoArchiveDays: ( + req: TypesGen.UpdateChatAutoArchiveDaysRequest, + options?: MutationCallbacks, + ) => void; + isSavingAutoArchiveDays: boolean; + isSaveAutoArchiveDaysError: boolean; +} + +// Keep in sync with autoArchiveDaysMaximum in coderd/exp_chats.go. +const validationSchema = Yup.object({ + auto_archive_days: Yup.number() + .integer("Auto-archive days must be a whole number.") + .min(1, "Auto-archive period must be at least 1 day.") + .max(3650, "Must not exceed 3650 days (~10 years).") + .required("Auto-archive days is required."), +}); + +// Sensible default offered when an admin enables auto-archive for +// the first time. Distinct from the server default (0 = disabled). +const ENABLE_DEFAULT_DAYS = 90; + +export const AutoArchiveSettings: FC = ({ + autoArchiveDaysData, + isAutoArchiveDaysLoading, + isAutoArchiveDaysLoadError, + onSaveAutoArchiveDays, + isSavingAutoArchiveDays, + isSaveAutoArchiveDaysError, +}) => { + const [archiveToggled, setArchiveToggled] = useState(null); + const { isSavedVisible, showSavedState } = useTemporarySavedState(); + + const serverAutoArchiveDays = + autoArchiveDaysData?.auto_archive_days ?? DefaultChatAutoArchiveDays; + const isAutoArchiveEnabled = archiveToggled ?? serverAutoArchiveDays > 0; + + const form = useFormik({ + initialValues: { auto_archive_days: serverAutoArchiveDays }, + enableReinitialize: true, + validationSchema, + onSubmit: (values, helpers) => { + onSaveAutoArchiveDays( + { auto_archive_days: values.auto_archive_days }, + { + onSuccess: () => { + showSavedState(); + setArchiveToggled(null); + helpers.resetForm(); + }, + }, + ); + }, + }); + + const resetArchiveState = () => { + setArchiveToggled(null); + form.resetForm(); + }; + + const handleToggleAutoArchive = (checked: boolean) => { + if (checked) { + const days = + serverAutoArchiveDays > 0 ? serverAutoArchiveDays : ENABLE_DEFAULT_DAYS; + setArchiveToggled(true); + void form.setFieldValue("auto_archive_days", days); + onSaveAutoArchiveDays( + { auto_archive_days: days }, + { + onSuccess: resetArchiveState, + onError: resetArchiveState, + }, + ); + } else { + setArchiveToggled(false); + void form.setFieldValue("auto_archive_days", 0); + onSaveAutoArchiveDays( + { auto_archive_days: 0 }, + { + onSuccess: resetArchiveState, + onError: resetArchiveState, + }, + ); + } + }; + + return ( +
+
+
+

+ Auto-Archive Inactive Conversations +

+
+ +
+

+ Inactive conversations are automatically archived after this period. + Pinned conversations are exempt. +

+ {isAutoArchiveEnabled && ( + <> +
+ + + Days + +
+ {form.errors.auto_archive_days && form.touched.auto_archive_days && ( +

+ {form.errors.auto_archive_days} +

+ )} +
+ {(form.dirty || isSavedVisible || isSavingAutoArchiveDays) && + (isSavedVisible ? ( + + ) : ( + + ))} +
+ + )} + {isSaveAutoArchiveDaysError && ( +

+ Failed to save auto-archive setting. +

+ )} + {isAutoArchiveDaysLoadError && ( +

+ Failed to load auto-archive setting. +

+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx b/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx index 0487739537..b4cc87f886 100644 --- a/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx +++ b/site/src/pages/AgentsPage/components/RetentionPeriodSettings.tsx @@ -123,18 +123,25 @@ export const RetentionPeriodSettings: FC = ({

{isRetentionEnabled && ( <> - +
+ + + Days + +
{form.errors.retention_days && form.touched.retention_days && (

{form.errors.retention_days}