Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 776f287

Browse files
authored
feat: allow admins to create workspaces for other users in UI (#4247)
* added permission for creating a workspace on behalf of a user * committing stashed files * hooked up autocomplete for users * added label * added translations * wrote test * added inputMargin prop * fixed permissions * added inputSTyle prop * ran prettier * fix lint
1 parent 70d7dd9 commit 776f287

File tree

9 files changed

+169
-74
lines changed

9 files changed

+169
-74
lines changed

site/src/components/UserAutocomplete/UserAutocomplete.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,20 @@ import { ChangeEvent, useEffect, useState } from "react"
1010
import { searchUserMachine } from "xServices/users/searchUserXService"
1111

1212
export type UserAutocompleteProps = {
13-
value?: User
13+
value: User | null
1414
onChange: (user: User | null) => void
15+
label?: string
16+
inputMargin?: "none" | "dense" | "normal"
17+
inputStyles?: string
1518
}
1619

17-
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onChange }) => {
20+
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
21+
value,
22+
onChange,
23+
label,
24+
inputMargin,
25+
inputStyles,
26+
}) => {
1827
const styles = useStyles()
1928
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
2029
const [searchState, sendSearch] = useMachine(searchUserMachine)
@@ -77,9 +86,11 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onCha
7786
renderInput={(params) => (
7887
<TextField
7988
{...params}
80-
margin="none"
8189
variant="outlined"
90+
margin={inputMargin ?? "normal"}
91+
label={label ?? undefined}
8292
placeholder="User email or username"
93+
className={inputStyles}
8394
InputProps={{
8495
...params.InputProps,
8596
onChange: handleFilterChange,
@@ -111,7 +122,7 @@ export const useStyles = makeStyles((theme) => {
111122
},
112123

113124
"& input": {
114-
fontSize: 14,
125+
fontSize: 16,
115126
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
116127
},
117128
},
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"templateLabel": "Template",
3+
"nameLabel": "Name",
4+
"ownerLabel": "Workspace Owner"
5+
}

site/src/i18n/en/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import auditLog from "./auditLog.json"
22
import common from "./common.json"
3+
import createWorkspacePage from "./createWorkspacePage.json"
34
import templatePage from "./templatePage.json"
45
import templatesPage from "./templatesPage.json"
56
import workspacePage from "./workspacePage.json"
@@ -10,4 +11,5 @@ export const en = {
1011
auditLog,
1112
templatePage,
1213
templatesPage,
14+
createWorkspacePage,
1315
}
Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
/* eslint-disable @typescript-eslint/no-floating-promises */
2-
import { screen } from "@testing-library/react"
2+
import { fireEvent, screen, waitFor } from "@testing-library/react"
33
import userEvent from "@testing-library/user-event"
44
import * as API from "api/api"
5-
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
6-
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
7-
import { renderWithAuth } from "../../testHelpers/renderHelpers"
5+
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
6+
import i18next from "i18next"
7+
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
8+
import { renderWithAuth } from "testHelpers/renderHelpers"
89
import CreateWorkspacePage from "./CreateWorkspacePage"
9-
import { Language } from "./CreateWorkspacePageView"
10+
11+
const { t } = i18next
12+
13+
const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })
1014

1115
const renderCreateWorkspacePage = () => {
1216
return renderWithAuth(<CreateWorkspacePage />, {
@@ -22,14 +26,26 @@ describe("CreateWorkspacePage", () => {
2226
expect(element).toBeDefined()
2327
})
2428

25-
it("succeeds", async () => {
29+
it("succeeds with default owner", async () => {
30+
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
2631
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
2732

2833
renderCreateWorkspacePage()
2934

30-
const nameField = await screen.findByLabelText(Language.nameLabel)
31-
userEvent.type(nameField, "test")
35+
const nameField = await screen.findByLabelText(nameLabelText)
36+
37+
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
38+
fireEvent.change(nameField, {
39+
target: { value: "test" },
40+
})
41+
3242
const submitButton = screen.getByText(FooterLanguage.defaultSubmitLabel)
3343
userEvent.click(submitButton)
44+
45+
await waitFor(() =>
46+
expect(API.createWorkspace).toBeCalledWith(MockUser.organization_ids[0], MockUser.id, {
47+
...MockWorkspaceRequest,
48+
}),
49+
)
3450
})
3551
})

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { useMachine } from "@xstate/react"
2-
import { FC } from "react"
1+
import { useActor, useMachine } from "@xstate/react"
2+
import { User } from "api/typesGenerated"
3+
import { useOrganizationId } from "hooks/useOrganizationId"
4+
import { FC, useContext, useState } from "react"
35
import { Helmet } from "react-helmet-async"
46
import { useNavigate, useParams } from "react-router-dom"
5-
import { useOrganizationId } from "../../hooks/useOrganizationId"
6-
import { pageTitle } from "../../util/page"
7-
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
7+
import { pageTitle } from "util/page"
8+
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
9+
import { XServiceContext } from "xServices/StateContext"
810
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
911

1012
const CreateWorkspacePage: FC = () => {
@@ -28,8 +30,15 @@ const CreateWorkspacePage: FC = () => {
2830
getTemplateSchemaError,
2931
getTemplatesError,
3032
createWorkspaceError,
33+
permissions,
3134
} = createWorkspaceState.context
3235

36+
const xServices = useContext(XServiceContext)
37+
const [authState] = useActor(xServices.authXService)
38+
const { me } = authState.context
39+
40+
const [owner, setOwner] = useState<User | null>(me ?? null)
41+
3342
return (
3443
<>
3544
<Helmet>
@@ -49,13 +58,17 @@ const CreateWorkspacePage: FC = () => {
4958
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
5059
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
5160
}}
61+
canCreateForUser={permissions?.createWorkspaceForUser}
62+
defaultWorkspaceOwner={me ?? null}
63+
setOwner={setOwner}
5264
onCancel={() => {
5365
navigate("/templates")
5466
}}
5567
onSubmit={(request) => {
5668
send({
5769
type: "CREATE_WORKSPACE",
5870
request,
71+
owner,
5972
})
6073
}}
6174
/>

site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1-
import { makeStyles } from "@material-ui/core/styles"
21
import TextField from "@material-ui/core/TextField"
2+
import * as TypesGen from "api/typesGenerated"
33
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
4+
import { FormFooter } from "components/FormFooter/FormFooter"
5+
import { FullPageForm } from "components/FullPageForm/FullPageForm"
6+
import { Loader } from "components/Loader/Loader"
7+
import { ParameterInput } from "components/ParameterInput/ParameterInput"
8+
import { Stack } from "components/Stack/Stack"
9+
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
410
import { FormikContextType, FormikTouched, useFormik } from "formik"
11+
import { i18n } from "i18n"
512
import { FC, useState } from "react"
13+
import { useTranslation } from "react-i18next"
14+
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
615
import * as Yup from "yup"
7-
import * as TypesGen from "../../api/typesGenerated"
8-
import { FormFooter } from "../../components/FormFooter/FormFooter"
9-
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
10-
import { Loader } from "../../components/Loader/Loader"
11-
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
12-
import { Stack } from "../../components/Stack/Stack"
13-
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils"
14-
15-
export const Language = {
16-
templateLabel: "Template",
17-
nameLabel: "Name",
18-
}
1916

2017
export enum CreateWorkspaceErrors {
2118
GET_TEMPLATES_ERROR = "getTemplatesError",
@@ -33,21 +30,27 @@ export interface CreateWorkspacePageViewProps {
3330
selectedTemplate?: TypesGen.Template
3431
templateSchema?: TypesGen.ParameterSchema[]
3532
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
33+
canCreateForUser?: boolean
34+
defaultWorkspaceOwner: TypesGen.User | null
35+
setOwner: (arg0: TypesGen.User | null) => void
3636
onCancel: () => void
3737
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
3838
// initialTouched is only used for testing the error state of the form.
3939
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
4040
}
4141

42+
const { t } = i18n
43+
4244
export const validationSchema = Yup.object({
43-
name: nameValidator(Language.nameLabel),
45+
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
4446
})
4547

4648
export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspacePageViewProps>> = (
4749
props,
4850
) => {
51+
const { t } = useTranslation("createWorkspacePage")
52+
4953
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
50-
useStyles()
5154

5255
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
5356
useFormik<TypesGen.CreateWorkspaceRequest>({
@@ -114,17 +117,15 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
114117
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
115118
<form onSubmit={form.handleSubmit}>
116119
<Stack>
117-
{props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] ? (
120+
{Boolean(props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]) && (
118121
<ErrorSummary
119122
error={props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]}
120123
/>
121-
) : (
122-
<></>
123124
)}
124125
<TextField
125126
disabled
126127
fullWidth
127-
label={Language.templateLabel}
128+
label={t("templateLabel")}
128129
value={props.selectedTemplate?.name || props.templateName}
129130
variant="outlined"
130131
/>
@@ -138,10 +139,19 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
138139
onChange={onChangeTrimmed(form)}
139140
autoFocus
140141
fullWidth
141-
label={Language.nameLabel}
142+
label={t("nameLabel")}
142143
variant="outlined"
143144
/>
144145

146+
{props.canCreateForUser && (
147+
<UserAutocomplete
148+
value={props.defaultWorkspaceOwner}
149+
onChange={(user) => props.setOwner(user)}
150+
label={t("ownerLabel")}
151+
inputMargin="dense"
152+
/>
153+
)}
154+
145155
{props.templateSchema.length > 0 && (
146156
<Stack>
147157
{props.templateSchema.map((schema) => (
@@ -168,33 +178,3 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
168178
</FullPageForm>
169179
)
170180
}
171-
172-
const useStyles = makeStyles((theme) => ({
173-
readMoreLink: {
174-
display: "flex",
175-
alignItems: "center",
176-
177-
"& svg": {
178-
width: 12,
179-
height: 12,
180-
marginLeft: theme.spacing(0.5),
181-
},
182-
},
183-
emptyState: {
184-
padding: 0,
185-
fontFamily: "inherit",
186-
textAlign: "left",
187-
minHeight: "auto",
188-
alignItems: "flex-start",
189-
},
190-
emptyStateDescription: {
191-
lineHeight: "160%",
192-
},
193-
code: {
194-
background: theme.palette.background.paper,
195-
width: "100%",
196-
},
197-
codeButton: {
198-
background: theme.palette.background.paper,
199-
},
200-
}))

site/src/testHelpers/entities.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,13 @@ export const MockQueuedWorkspace: TypesGen.Workspace = {
324324
},
325325
}
326326

327+
// requests the MockWorkspace
328+
export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
329+
name: "test",
330+
parameter_values: [],
331+
template_id: "test-template",
332+
}
333+
327334
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
328335
id: "test-app",
329336
name: "test-app",

0 commit comments

Comments
 (0)