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

Skip to content

feat: allow admins to create workspaces for other users in UI #4247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Sep 29, 2022
19 changes: 15 additions & 4 deletions site/src/components/UserAutocomplete/UserAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ import { ChangeEvent, useEffect, useState } from "react"
import { searchUserMachine } from "xServices/users/searchUserXService"

export type UserAutocompleteProps = {
value?: User
value: User | null
onChange: (user: User | null) => void
label?: string
inputMargin?: "none" | "dense" | "normal"
inputStyles?: string
}

export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onChange }) => {
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
value,
onChange,
label,
inputMargin,
inputStyles,
}) => {
const styles = useStyles()
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
Expand Down Expand Up @@ -77,9 +86,11 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onCha
renderInput={(params) => (
<TextField
{...params}
margin="none"
variant="outlined"
margin={inputMargin ?? "normal"}
label={label ?? undefined}
placeholder="User email or username"
className={inputStyles}
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
Expand Down Expand Up @@ -111,7 +122,7 @@ export const useStyles = makeStyles((theme) => {
},

"& input": {
fontSize: 14,
fontSize: 16,
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
},
},
Expand Down
5 changes: 5 additions & 0 deletions site/src/i18n/en/createWorkspacePage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"templateLabel": "Template",
"nameLabel": "Name",
"ownerLabel": "Workspace Owner"
}
2 changes: 2 additions & 0 deletions site/src/i18n/en/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import auditLog from "./auditLog.json"
import common from "./common.json"
import createWorkspacePage from "./createWorkspacePage.json"
import templatePage from "./templatePage.json"
import templatesPage from "./templatesPage.json"
import workspacePage from "./workspacePage.json"
Expand All @@ -10,4 +11,5 @@ export const en = {
auditLog,
templatePage,
templatesPage,
createWorkspacePage,
}
32 changes: 24 additions & 8 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { screen } from "@testing-library/react"
import { fireEvent, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
import i18next from "i18next"
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import CreateWorkspacePage from "./CreateWorkspacePage"
import { Language } from "./CreateWorkspacePageView"

const { t } = i18next

const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })

const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
Expand All @@ -22,14 +26,26 @@ describe("CreateWorkspacePage", () => {
expect(element).toBeDefined()
})

it("succeeds", async () => {
it("succeeds with default owner", async () => {
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)

renderCreateWorkspacePage()

const nameField = await screen.findByLabelText(Language.nameLabel)
userEvent.type(nameField, "test")
const nameField = await screen.findByLabelText(nameLabelText)

// have to use fireEvent b/c userEvent isn't cleaning up properly between tests
fireEvent.change(nameField, {
target: { value: "test" },
})

const submitButton = screen.getByText(FooterLanguage.defaultSubmitLabel)
userEvent.click(submitButton)

await waitFor(() =>
expect(API.createWorkspace).toBeCalledWith(MockUser.organization_ids[0], MockUser.id, {
...MockWorkspaceRequest,
}),
)
})
})
23 changes: 18 additions & 5 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { useActor, useMachine } from "@xstate/react"
import { User } from "api/typesGenerated"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC, useContext, useState } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { useOrganizationId } from "../../hooks/useOrganizationId"
import { pageTitle } from "../../util/page"
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
import { pageTitle } from "util/page"
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import { XServiceContext } from "xServices/StateContext"
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"

const CreateWorkspacePage: FC = () => {
Expand All @@ -28,8 +30,15 @@ const CreateWorkspacePage: FC = () => {
getTemplateSchemaError,
getTemplatesError,
createWorkspaceError,
permissions,
} = createWorkspaceState.context

const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const { me } = authState.context

const [owner, setOwner] = useState<User | null>(me ?? null)

return (
<>
<Helmet>
Expand All @@ -49,13 +58,17 @@ const CreateWorkspacePage: FC = () => {
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
defaultWorkspaceOwner={me ?? null}
setOwner={setOwner}
onCancel={() => {
navigate("/templates")
}}
onSubmit={(request) => {
send({
type: "CREATE_WORKSPACE",
request,
owner,
})
}}
/>
Expand Down
80 changes: 30 additions & 50 deletions site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import * as TypesGen from "api/typesGenerated"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { FormFooter } from "components/FormFooter/FormFooter"
import { FullPageForm } from "components/FullPageForm/FullPageForm"
import { Loader } from "components/Loader/Loader"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { i18n } from "i18n"
import { FC, useState } from "react"
import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated"
import { FormFooter } from "../../components/FormFooter/FormFooter"
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
import { Loader } from "../../components/Loader/Loader"
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
import { Stack } from "../../components/Stack/Stack"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils"

export const Language = {
templateLabel: "Template",
nameLabel: "Name",
}

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

const { t } = i18n

export const validationSchema = Yup.object({
name: nameValidator(Language.nameLabel),
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
})

export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspacePageViewProps>> = (
props,
) => {
const { t } = useTranslation("createWorkspacePage")

const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
useStyles()

const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
Expand Down Expand Up @@ -114,17 +117,15 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
<Stack>
{props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] ? (
{Boolean(props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]) && (
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]}
/>
) : (
<></>
)}
<TextField
disabled
fullWidth
label={Language.templateLabel}
label={t("templateLabel")}
value={props.selectedTemplate?.name || props.templateName}
variant="outlined"
/>
Expand All @@ -138,10 +139,19 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={Language.nameLabel}
label={t("nameLabel")}
variant="outlined"
/>

{props.canCreateForUser && (
<UserAutocomplete
value={props.defaultWorkspaceOwner}
onChange={(user) => props.setOwner(user)}
label={t("ownerLabel")}
inputMargin="dense"
/>
)}

{props.templateSchema.length > 0 && (
<Stack>
{props.templateSchema.map((schema) => (
Expand All @@ -168,33 +178,3 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
</FullPageForm>
)
}

const useStyles = makeStyles((theme) => ({
readMoreLink: {
display: "flex",
alignItems: "center",

"& svg": {
width: 12,
height: 12,
marginLeft: theme.spacing(0.5),
},
},
emptyState: {
padding: 0,
fontFamily: "inherit",
textAlign: "left",
minHeight: "auto",
alignItems: "flex-start",
},
emptyStateDescription: {
lineHeight: "160%",
},
code: {
background: theme.palette.background.paper,
width: "100%",
},
codeButton: {
background: theme.palette.background.paper,
},
}))
7 changes: 7 additions & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ export const MockQueuedWorkspace: TypesGen.Workspace = {
},
}

// requests the MockWorkspace
export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
name: "test",
parameter_values: [],
template_id: "test-template",
}

export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
id: "test-app",
name: "test-app",
Expand Down
Loading