Skip to content

Commit 58ecefa

Browse files
committed
feat: edit workspace schedule page
1 parent 9b70a9b commit 58ecefa

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const Language = {
3232

3333
export interface WorkspaceScheduleFormProps {
3434
onCancel: () => void
35+
36+
// TODO(Grey): un-promisfy and adding isSubmitting prop
3537
onSubmit: (values: WorkspaceScheduleFormValues) => Promise<void>
3638
}
3739

@@ -73,6 +75,7 @@ export const validationSchema = Yup.object({
7375
friday: Yup.boolean(),
7476
saturday: Yup.boolean(),
7577

78+
// TODO(Grey): Add validation that the string is "" or "HH:mm" (24 hours)
7679
startTime: Yup.string(),
7780
ttl: Yup.number().min(0).integer(),
7881
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useMachine } from "@xstate/react"
2+
import React, { useEffect } from "react"
3+
import { useNavigate, useParams } from "react-router-dom"
4+
import * as TypesGen from "../../api/typesGenerated"
5+
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
6+
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
7+
import {
8+
WorkspaceScheduleForm,
9+
WorkspaceScheduleFormValues,
10+
} from "../../components/WorkspaceStats/WorkspaceScheduleForm"
11+
import { firstOrItem } from "../../util/array"
12+
import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService"
13+
14+
// TODO(Grey): Test before opening PR from draft
15+
export const formValuesToAutoStartRequest = (
16+
values: WorkspaceScheduleFormValues,
17+
): TypesGen.UpdateWorkspaceAutostartRequest => {
18+
if (!values.startTime) {
19+
return {
20+
schedule: "",
21+
}
22+
}
23+
24+
// TODO(Grey): Fill in
25+
return {
26+
schedule: "9 30 * * 1-5",
27+
}
28+
}
29+
30+
export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => {
31+
if (!values.ttl) {
32+
return {
33+
ttl: 0, // TODO(Grey): Verify with Cian whether 0 or null is better to send
34+
}
35+
}
36+
37+
// TODO(Grey): Fill in
38+
return {
39+
ttl: 0,
40+
}
41+
}
42+
43+
// TODO(Grey): React testing library for this
44+
export const WorkspaceSchedulePage: React.FC = () => {
45+
const navigate = useNavigate()
46+
const { workspace: workspaceQueryParam } = useParams()
47+
const workspaceId = firstOrItem(workspaceQueryParam, null)
48+
49+
// TODO(Grey): Consume the formSubmissionErrors in WorkspaceScheduleForm
50+
const [scheduleState, scheduleSend] = useMachine(workspaceSchedule)
51+
const { getWorkspaceError, workspace } = scheduleState.context
52+
53+
/**
54+
* Get workspace on mount and whenever workspaceId changes (scheduleSend
55+
* should not change).
56+
*/
57+
useEffect(() => {
58+
workspaceId && scheduleSend({ type: "GET_WORKSPACE", workspaceId })
59+
}, [workspaceId, scheduleSend])
60+
61+
if (!workspaceId) {
62+
navigate("/workspaces")
63+
return null
64+
} else if (scheduleState.matches("error")) {
65+
return <ErrorSummary error={getWorkspaceError} retry={() => scheduleSend({ type: "GET_WORKSPACE", workspaceId })} />
66+
} else if (!workspace) {
67+
return <FullScreenLoader />
68+
} else {
69+
return (
70+
<WorkspaceScheduleForm
71+
onCancel={() => {
72+
navigate(`/workspaces/${workspaceId}`)
73+
}}
74+
onSubmit={(values) => {
75+
scheduleSend({
76+
type: "SUBMIT_SCHEDULE",
77+
autoStart: formValuesToAutoStartRequest(values),
78+
ttl: formValuesToTTLRequest(values),
79+
})
80+
81+
// TODO(Grey): Remove this after onSubmit is un-promisified
82+
// TODO(Grey): navigation logic
83+
return Promise.resolve()
84+
}}
85+
/>
86+
)
87+
}
88+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* @fileoverview workspaceSchedule is an xstate machine backing a form to CRUD
3+
* an individual workspace's schedule.
4+
*/
5+
import { assign, createMachine } from "xstate"
6+
import * as API from "../../api/api"
7+
import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors"
8+
import * as TypesGen from "../../api/typesGenerated"
9+
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
10+
11+
export const Language = {
12+
errorSubmissionFailed: "Failed to update schedule",
13+
errorWorkspaceFetch: "Failed to fetch workspace",
14+
successMessage: "Successfully updated workspace schedule.",
15+
}
16+
17+
export interface WorkspaceScheduleContext {
18+
formErrors?: FieldErrors
19+
getWorkspaceError?: Error | unknown
20+
/**
21+
* Each workspace has their own schedule (start and ttl). For this reason, we
22+
* re-fetch the workspace to ensure we're up-to-date. As a result, this
23+
* machine is partially influenced by workspaceXService.
24+
*/
25+
workspace?: TypesGen.Workspace
26+
}
27+
28+
export type WorkspaceScheduleEvent =
29+
| { type: "GET_WORKSPACE"; workspaceId: string }
30+
| {
31+
type: "SUBMIT_SCHEDULE"
32+
autoStart: TypesGen.UpdateWorkspaceAutostartRequest
33+
ttl: TypesGen.UpdateWorkspaceTTLRequest
34+
}
35+
36+
export const workspaceSchedule = createMachine(
37+
{
38+
tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0,
39+
schema: {
40+
context: {} as WorkspaceScheduleContext,
41+
events: {} as WorkspaceScheduleEvent,
42+
services: {} as {
43+
getWorkspace: {
44+
data: TypesGen.Workspace
45+
}
46+
},
47+
},
48+
id: "workspaceScheduleState",
49+
initial: "idle",
50+
on: {
51+
GET_WORKSPACE: "gettingWorkspace",
52+
},
53+
states: {
54+
idle: {
55+
tags: "loading",
56+
},
57+
gettingWorkspace: {
58+
entry: ["clearGetWorkspaceError", "clearContext"],
59+
invoke: {
60+
src: "getWorkspace",
61+
id: "getWorkspace",
62+
onDone: {
63+
target: "presentForm",
64+
actions: ["assignWorkspace"],
65+
},
66+
onError: {
67+
target: "error",
68+
actions: ["assignGetWorkspaceError", "displayWorkspaceError"],
69+
},
70+
},
71+
tags: "loading",
72+
},
73+
presentForm: {
74+
on: {
75+
SUBMIT_SCHEDULE: "submittingSchedule",
76+
},
77+
},
78+
submittingSchedule: {
79+
invoke: {
80+
src: "submitSchedule",
81+
id: "submitSchedule",
82+
onDone: {
83+
target: "idle",
84+
actions: "displaySuccess",
85+
},
86+
onError: {
87+
target: "presentForm",
88+
actions: ["assignSubmissionError", "displaySubmissionError"],
89+
},
90+
},
91+
tags: "loading",
92+
},
93+
error: {
94+
on: {
95+
GET_WORKSPACE: "gettingWorkspace",
96+
},
97+
},
98+
},
99+
},
100+
{
101+
actions: {
102+
assignSubmissionError: assign({
103+
formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
104+
}),
105+
assignWorkspace: assign({
106+
workspace: (_, event) => event.data,
107+
}),
108+
assignGetWorkspaceError: assign({
109+
getWorkspaceError: (_, event) => event.data,
110+
}),
111+
clearContext: () => {
112+
assign({ workspace: undefined })
113+
},
114+
clearGetWorkspaceError: (context) => {
115+
assign({ ...context, getWorkspaceError: undefined })
116+
},
117+
displayWorkspaceError: () => {
118+
displayError(Language.errorWorkspaceFetch)
119+
},
120+
displaySubmissionError: () => {
121+
displayError(Language.errorSubmissionFailed)
122+
},
123+
displaySuccess: () => {
124+
displaySuccess(Language.successMessage)
125+
},
126+
},
127+
128+
services: {
129+
getWorkspace: async (_, event) => {
130+
return await API.getWorkspace(event.workspaceId)
131+
},
132+
submitSchedule: async (context, event) => {
133+
if (!context.workspace?.id) {
134+
// This state is theoretically impossible, but helps TS
135+
throw new Error("failed to load workspace")
136+
}
137+
138+
// REMARK: These calls are purposefully synchronous because if one
139+
// value contradicts the other, we don't want a race condition
140+
// on re-submission.
141+
await API.putWorkspaceAutostart(context.workspace.id, event.autoStart)
142+
await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
143+
},
144+
},
145+
},
146+
)

0 commit comments

Comments
 (0)