mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Create user page (#1197)
* Add button and route
* Hook up api
* Lint
* Add basic form
* Get users on page mount
* Make cancel work
* Creating -> idle bc users page refetches
* Import as TypesGen
* Handle api errors
* Lint
* Add handler
* Add FormFooter
* Add FullPageForm
* Lint
* Better form, error, stories
bug in formErrors story
* Make detail optional
* Use Language
* Remove detail prop
* Add back autoFocus
* Remove displayError, use displaySuccess
* Lint, export Language
* Tests - wip
* Fix cancel tests
* Switch back to mock
* Add navigate to xservice
Doesn't work in test
* Move error type predicate to xservice
* Lint
* Switch to using creation mode in XState
still problems in tests
* Lint
* Lint
* Lint
* Revert "Switch to using creation mode in XState"
This reverts commit cf8442fa4b
.
* Give XService a navigate action
* Add missing validation messages
* Fix XState warning
* Fix tests
IRL is broken bc I need to send org id
* Pretend user has org id and make it work
* Format
* Lint
* Switch to org ids array
* Skip lines between tests
Co-authored-by: G r e y <grey@coder.com>
* Punctuate notification messages
Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
|
|||||||
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
|
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
|
||||||
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
|
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
|
||||||
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
|
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
|
||||||
|
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
|
||||||
import { UsersPage } from "./pages/UsersPage/UsersPage"
|
import { UsersPage } from "./pages/UsersPage/UsersPage"
|
||||||
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
|
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
|
||||||
|
|
||||||
@ -83,14 +84,24 @@ export const AppRouter: React.FC = () => (
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route path="users">
|
||||||
path="users"
|
<Route
|
||||||
element={
|
index
|
||||||
<AuthAndFrame>
|
element={
|
||||||
<UsersPage />
|
<AuthAndFrame>
|
||||||
</AuthAndFrame>
|
<UsersPage />
|
||||||
}
|
</AuthAndFrame>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="create"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<CreateUserPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="orgs"
|
path="orgs"
|
||||||
element={
|
element={
|
||||||
|
@ -11,7 +11,7 @@ interface FieldError {
|
|||||||
detail: string
|
detail: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
|
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
|
||||||
|
|
||||||
export interface ApiErrorResponse {
|
export interface ApiErrorResponse {
|
||||||
message: string
|
message: string
|
||||||
|
@ -85,6 +85,11 @@ export const getUsers = async (): Promise<Types.PagedUsers> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createUser = async (user: Types.CreateUserRequest): Promise<TypesGen.User> => {
|
||||||
|
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
|
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
|
||||||
const response = await axios.get("/api/v2/buildinfo")
|
const response = await axios.get("/api/v2/buildinfo")
|
||||||
return response.data
|
return response.data
|
||||||
|
@ -10,11 +10,20 @@ export interface LoginResponse {
|
|||||||
session_token: string
|
session_token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
organization_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserResponse {
|
export interface UserResponse {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly username: string
|
readonly username: string
|
||||||
readonly email: string
|
readonly email: string
|
||||||
readonly created_at: string
|
readonly created_at: string
|
||||||
|
readonly status: "active" | "suspended"
|
||||||
|
readonly organization_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
import { action } from "@storybook/addon-actions"
|
||||||
|
import { Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { CreateUserForm, CreateUserFormProps } from "./CreateUserForm"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/CreateUserForm",
|
||||||
|
component: CreateUserForm,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story<CreateUserFormProps> = (args: CreateUserFormProps) => <CreateUserForm {...args} />
|
||||||
|
|
||||||
|
export const Ready = Template.bind({})
|
||||||
|
Ready.args = {
|
||||||
|
onCancel: action("cancel"),
|
||||||
|
onSubmit: action("submit"),
|
||||||
|
isLoading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnknownError = Template.bind({})
|
||||||
|
UnknownError.args = {
|
||||||
|
onCancel: action("cancel"),
|
||||||
|
onSubmit: action("submit"),
|
||||||
|
isLoading: false,
|
||||||
|
error: "Something went wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormError = Template.bind({})
|
||||||
|
FormError.args = {
|
||||||
|
onCancel: action("cancel"),
|
||||||
|
onSubmit: action("submit"),
|
||||||
|
isLoading: false,
|
||||||
|
formErrors: {
|
||||||
|
username: "Username taken",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading = Template.bind({})
|
||||||
|
Loading.args = {
|
||||||
|
onCancel: action("cancel"),
|
||||||
|
onSubmit: action("submit"),
|
||||||
|
isLoading: true,
|
||||||
|
}
|
92
site/src/components/CreateUserForm/CreateUserForm.tsx
Normal file
92
site/src/components/CreateUserForm/CreateUserForm.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import FormHelperText from "@material-ui/core/FormHelperText"
|
||||||
|
import TextField from "@material-ui/core/TextField"
|
||||||
|
import { FormikContextType, FormikErrors, useFormik } from "formik"
|
||||||
|
import React from "react"
|
||||||
|
import * as Yup from "yup"
|
||||||
|
import { CreateUserRequest } from "../../api/types"
|
||||||
|
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
|
||||||
|
import { FormFooter } from "../FormFooter/FormFooter"
|
||||||
|
import { FullPageForm } from "../FullPageForm/FullPageForm"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
emailLabel: "Email",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
emailInvalid: "Please enter a valid email address.",
|
||||||
|
emailRequired: "Please enter an email address.",
|
||||||
|
passwordRequired: "Please enter a password.",
|
||||||
|
usernameRequired: "Please enter a username.",
|
||||||
|
createUser: "Create",
|
||||||
|
cancel: "Cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserFormProps {
|
||||||
|
onSubmit: (user: CreateUserRequest) => void
|
||||||
|
onCancel: () => void
|
||||||
|
formErrors?: FormikErrors<CreateUserRequest>
|
||||||
|
isLoading: boolean
|
||||||
|
error?: string
|
||||||
|
myOrgId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationSchema = Yup.object({
|
||||||
|
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
|
||||||
|
password: Yup.string().required(Language.passwordRequired),
|
||||||
|
username: Yup.string().required(Language.usernameRequired),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreateUserForm: React.FC<CreateUserFormProps> = ({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
formErrors,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
myOrgId,
|
||||||
|
}) => {
|
||||||
|
const form: FormikContextType<CreateUserRequest> = useFormik<CreateUserRequest>({
|
||||||
|
initialValues: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
username: "",
|
||||||
|
organization_id: myOrgId,
|
||||||
|
},
|
||||||
|
validationSchema,
|
||||||
|
onSubmit,
|
||||||
|
})
|
||||||
|
const getFieldHelpers = getFormHelpers<CreateUserRequest>(form, formErrors)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullPageForm title="Create user" onCancel={onCancel}>
|
||||||
|
<form onSubmit={form.handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("username")}
|
||||||
|
onChange={onChangeTrimmed(form)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label={Language.usernameLabel}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("email")}
|
||||||
|
onChange={onChangeTrimmed(form)}
|
||||||
|
autoComplete="email"
|
||||||
|
fullWidth
|
||||||
|
label={Language.emailLabel}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("password")}
|
||||||
|
autoComplete="current-password"
|
||||||
|
fullWidth
|
||||||
|
id="password"
|
||||||
|
label={Language.passwordLabel}
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||||
|
<FormFooter onCancel={onCancel} isLoading={isLoading} />
|
||||||
|
</form>
|
||||||
|
</FullPageForm>
|
||||||
|
)
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { LoadingButton } from "../LoadingButton/LoadingButton"
|
import { LoadingButton } from "../LoadingButton/LoadingButton"
|
||||||
|
|
||||||
const Language = {
|
export const Language = {
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
defaultSubmitLabel: "Submit",
|
defaultSubmitLabel: "Submit",
|
||||||
}
|
}
|
||||||
|
@ -34,8 +34,10 @@ describe("AccountPage", () => {
|
|||||||
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
|
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
id: userId,
|
id: userId,
|
||||||
...data,
|
|
||||||
created_at: new Date().toString(),
|
created_at: new Date().toString(),
|
||||||
|
status: "active",
|
||||||
|
organization_ids: ["123"],
|
||||||
|
...data,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const { user } = renderPage()
|
const { user } = renderPage()
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
import { screen } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
|
import { rest } from "msw"
|
||||||
|
import React from "react"
|
||||||
|
import * as API from "../../../api"
|
||||||
|
import { Language as FormLanguage } from "../../../components/CreateUserForm/CreateUserForm"
|
||||||
|
import { Language as FooterLanguage } from "../../../components/FormFooter/FormFooter"
|
||||||
|
import { history, render } from "../../../testHelpers"
|
||||||
|
import { server } from "../../../testHelpers/server"
|
||||||
|
import { Language as UserLanguage } from "../../../xServices/users/usersXService"
|
||||||
|
import { CreateUserPage, Language } from "./CreateUserPage"
|
||||||
|
|
||||||
|
const fillForm = async ({
|
||||||
|
username = "someuser",
|
||||||
|
email = "someone@coder.com",
|
||||||
|
password = "password",
|
||||||
|
}: {
|
||||||
|
username?: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
}) => {
|
||||||
|
const usernameField = screen.getByLabelText(FormLanguage.usernameLabel)
|
||||||
|
const emailField = screen.getByLabelText(FormLanguage.emailLabel)
|
||||||
|
const passwordField = screen.getByLabelText(FormLanguage.passwordLabel)
|
||||||
|
await userEvent.type(usernameField, username)
|
||||||
|
await userEvent.type(emailField, email)
|
||||||
|
await userEvent.type(passwordField, password)
|
||||||
|
const submitButton = await screen.findByText(FooterLanguage.defaultSubmitLabel)
|
||||||
|
submitButton.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Create User Page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
history.replace("/users/create")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows validation error message", async () => {
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
await fillForm({ email: "test" })
|
||||||
|
const errorMessage = await screen.findByText(FormLanguage.emailInvalid)
|
||||||
|
expect(errorMessage).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows generic error message", async () => {
|
||||||
|
jest.spyOn(API, "createUser").mockRejectedValueOnce({
|
||||||
|
data: "unknown error",
|
||||||
|
})
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
await fillForm({})
|
||||||
|
const errorMessage = await screen.findByText(Language.unknownError)
|
||||||
|
expect(errorMessage).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows API error message", async () => {
|
||||||
|
const fieldErrorMessage = "username already in use"
|
||||||
|
server.use(
|
||||||
|
rest.post("/api/v2/users", async (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.status(400),
|
||||||
|
ctx.json({
|
||||||
|
message: "invalid field",
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
detail: fieldErrorMessage,
|
||||||
|
field: "username",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
await fillForm({})
|
||||||
|
const errorMessage = await screen.findByText(fieldErrorMessage)
|
||||||
|
expect(errorMessage).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows success notification and redirects to users page", async () => {
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
await fillForm({})
|
||||||
|
const successMessage = screen.findByText(UserLanguage.createUserSuccess)
|
||||||
|
expect(successMessage).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects to users page on cancel", async () => {
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
const cancelButton = await screen.findByText(FooterLanguage.cancelLabel)
|
||||||
|
cancelButton.click()
|
||||||
|
expect(history.location.pathname).toEqual("/users")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("redirects to users page on close", async () => {
|
||||||
|
render(<CreateUserPage />)
|
||||||
|
const closeButton = await screen.findByText("ESC")
|
||||||
|
closeButton.click()
|
||||||
|
expect(history.location.pathname).toEqual("/users")
|
||||||
|
})
|
||||||
|
})
|
33
site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx
Normal file
33
site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useActor, useSelector } from "@xstate/react"
|
||||||
|
import React, { useContext } from "react"
|
||||||
|
import { useNavigate } from "react-router"
|
||||||
|
import { CreateUserRequest } from "../../../api/types"
|
||||||
|
import { CreateUserForm } from "../../../components/CreateUserForm/CreateUserForm"
|
||||||
|
import { selectOrgId } from "../../../xServices/auth/authSelectors"
|
||||||
|
import { XServiceContext } from "../../../xServices/StateContext"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
unknownError: "Oops, an unknown error occurred.",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateUserPage: React.FC = () => {
|
||||||
|
const xServices = useContext(XServiceContext)
|
||||||
|
const myOrgId = useSelector(xServices.authXService, selectOrgId)
|
||||||
|
const [usersState, usersSend] = useActor(xServices.usersXService)
|
||||||
|
const { createUserError, createUserFormErrors } = usersState.context
|
||||||
|
const navigate = useNavigate()
|
||||||
|
// There is no field for organization id in Community Edition, so handle its field error like a generic error
|
||||||
|
const genericError =
|
||||||
|
createUserError || createUserFormErrors?.organization_id || !myOrgId ? Language.unknownError : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateUserForm
|
||||||
|
formErrors={createUserFormErrors}
|
||||||
|
onSubmit={(user: CreateUserRequest) => usersSend({ type: "CREATE", user })}
|
||||||
|
onCancel={() => navigate("/users")}
|
||||||
|
isLoading={usersState.hasTag("loading")}
|
||||||
|
error={genericError}
|
||||||
|
myOrgId={myOrgId ?? ""}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -1,17 +1,34 @@
|
|||||||
import { useActor } from "@xstate/react"
|
import { useActor } from "@xstate/react"
|
||||||
import React, { useContext } from "react"
|
import React, { useContext, useEffect } from "react"
|
||||||
|
import { useNavigate } from "react-router"
|
||||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||||
import { XServiceContext } from "../../xServices/StateContext"
|
import { XServiceContext } from "../../xServices/StateContext"
|
||||||
import { UsersPageView } from "./UsersPageView"
|
import { UsersPageView } from "./UsersPageView"
|
||||||
|
|
||||||
export const UsersPage: React.FC = () => {
|
export const UsersPage: React.FC = () => {
|
||||||
const xServices = useContext(XServiceContext)
|
const xServices = useContext(XServiceContext)
|
||||||
const [usersState] = useActor(xServices.usersXService)
|
const [usersState, usersSend] = useActor(xServices.usersXService)
|
||||||
const { users, pager, getUsersError } = usersState.context
|
const { users, pager, getUsersError } = usersState.context
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch users on component mount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
usersSend("GET_USERS")
|
||||||
|
}, [usersSend])
|
||||||
|
|
||||||
if (usersState.matches("error")) {
|
if (usersState.matches("error")) {
|
||||||
return <ErrorSummary error={getUsersError} />
|
return <ErrorSummary error={getUsersError} />
|
||||||
} else {
|
} else {
|
||||||
return <UsersPageView users={users} pager={pager} />
|
return (
|
||||||
|
<UsersPageView
|
||||||
|
users={users}
|
||||||
|
pager={pager}
|
||||||
|
openUserCreationDialog={() => {
|
||||||
|
navigate("/users/create")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,18 +7,25 @@ import { UsersTable } from "../../components/UsersTable/UsersTable"
|
|||||||
export const Language = {
|
export const Language = {
|
||||||
pageTitle: "Users",
|
pageTitle: "Users",
|
||||||
pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""),
|
pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""),
|
||||||
|
newUserButton: "New User",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersPageViewProps {
|
export interface UsersPageViewProps {
|
||||||
users: UserResponse[]
|
users: UserResponse[]
|
||||||
pager?: Pager
|
pager?: Pager
|
||||||
|
openUserCreationDialog: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager }) => {
|
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager, openUserCreationDialog }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.flexColumn}>
|
<div className={styles.flexColumn}>
|
||||||
<Header title={Language.pageTitle} subTitle={Language.pageSubtitle(pager)} />
|
<Header
|
||||||
|
title={Language.pageTitle}
|
||||||
|
subTitle={Language.pageSubtitle(pager)}
|
||||||
|
action={{ text: Language.newUserButton, onClick: openUserCreationDialog }}
|
||||||
|
/>
|
||||||
<UsersTable users={users} />
|
<UsersTable users={users} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -25,6 +25,8 @@ export const MockUser: UserResponse = {
|
|||||||
username: "TestUser",
|
username: "TestUser",
|
||||||
email: "test@coder.com",
|
email: "test@coder.com",
|
||||||
created_at: "",
|
created_at: "",
|
||||||
|
status: "active",
|
||||||
|
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockUser2: UserResponse = {
|
export const MockUser2: UserResponse = {
|
||||||
@ -32,6 +34,8 @@ export const MockUser2: UserResponse = {
|
|||||||
username: "TestUser2",
|
username: "TestUser2",
|
||||||
email: "test2@coder.com",
|
email: "test2@coder.com",
|
||||||
created_at: "",
|
created_at: "",
|
||||||
|
status: "active",
|
||||||
|
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockPager: Pager = {
|
export const MockPager: Pager = {
|
||||||
|
@ -24,6 +24,9 @@ export const handlers = [
|
|||||||
rest.get("/api/v2/users", async (req, res, ctx) => {
|
rest.get("/api/v2/users", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager }))
|
return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager }))
|
||||||
}),
|
}),
|
||||||
|
rest.post("/api/v2/users", async (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(M.MockUser))
|
||||||
|
}),
|
||||||
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
|
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||||
}),
|
}),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useInterpret } from "@xstate/react"
|
import { useInterpret } from "@xstate/react"
|
||||||
import React, { createContext } from "react"
|
import React, { createContext } from "react"
|
||||||
|
import { useNavigate } from "react-router"
|
||||||
import { ActorRefFrom } from "xstate"
|
import { ActorRefFrom } from "xstate"
|
||||||
import { authMachine } from "./auth/authXService"
|
import { authMachine } from "./auth/authXService"
|
||||||
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
||||||
@ -22,12 +23,17 @@ interface XServiceContextType {
|
|||||||
export const XServiceContext = createContext({} as XServiceContextType)
|
export const XServiceContext = createContext({} as XServiceContextType)
|
||||||
|
|
||||||
export const XServiceProvider: React.FC = ({ children }) => {
|
export const XServiceProvider: React.FC = ({ children }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const redirectToUsersPage = () => {
|
||||||
|
navigate("users")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XServiceContext.Provider
|
<XServiceContext.Provider
|
||||||
value={{
|
value={{
|
||||||
authXService: useInterpret(authMachine),
|
authXService: useInterpret(authMachine),
|
||||||
buildInfoXService: useInterpret(buildInfoMachine),
|
buildInfoXService: useInterpret(buildInfoMachine),
|
||||||
usersXService: useInterpret(usersMachine),
|
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
6
site/src/xServices/auth/authSelectors.ts
Normal file
6
site/src/xServices/auth/authSelectors.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { State } from "xstate"
|
||||||
|
import { AuthContext, AuthEvent } from "./authXService"
|
||||||
|
|
||||||
|
export const selectOrgId = (state: State<AuthContext, AuthEvent>): string | undefined => {
|
||||||
|
return state.context.me?.organization_ids[0]
|
||||||
|
}
|
@ -1,14 +1,23 @@
|
|||||||
import { assign, createMachine } from "xstate"
|
import { assign, createMachine } from "xstate"
|
||||||
import * as API from "../../api"
|
import * as API from "../../api"
|
||||||
|
import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../../api/errors"
|
||||||
import * as Types from "../../api/types"
|
import * as Types from "../../api/types"
|
||||||
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
|
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
createUserSuccess: "Successfully created user.",
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsersContext {
|
export interface UsersContext {
|
||||||
users: Types.UserResponse[]
|
users: Types.UserResponse[]
|
||||||
pager?: Types.Pager
|
pager?: Types.Pager
|
||||||
getUsersError?: Error | unknown
|
getUsersError?: Error | unknown
|
||||||
|
createUserError?: Error | unknown
|
||||||
|
createUserFormErrors?: FieldErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UsersEvent = { type: "GET_USERS" }
|
export type UsersEvent = { type: "GET_USERS" } | { type: "CREATE"; user: Types.CreateUserRequest }
|
||||||
|
|
||||||
export const usersMachine = createMachine(
|
export const usersMachine = createMachine(
|
||||||
{
|
{
|
||||||
@ -20,21 +29,30 @@ export const usersMachine = createMachine(
|
|||||||
getUsers: {
|
getUsers: {
|
||||||
data: Types.PagedUsers
|
data: Types.PagedUsers
|
||||||
}
|
}
|
||||||
|
createUser: {
|
||||||
|
data: TypesGen.User
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
id: "usersState",
|
id: "usersState",
|
||||||
context: {
|
context: {
|
||||||
users: [],
|
users: [],
|
||||||
},
|
},
|
||||||
initial: "gettingUsers",
|
initial: "idle",
|
||||||
states: {
|
states: {
|
||||||
|
idle: {
|
||||||
|
on: {
|
||||||
|
GET_USERS: "gettingUsers",
|
||||||
|
CREATE: "creatingUser",
|
||||||
|
},
|
||||||
|
},
|
||||||
gettingUsers: {
|
gettingUsers: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "getUsers",
|
src: "getUsers",
|
||||||
id: "getUsers",
|
id: "getUsers",
|
||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
target: "#usersState.ready",
|
target: "#usersState.idle",
|
||||||
actions: ["assignUsers", "clearGetUsersError"],
|
actions: ["assignUsers", "clearGetUsersError"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -47,10 +65,27 @@ export const usersMachine = createMachine(
|
|||||||
},
|
},
|
||||||
tags: "loading",
|
tags: "loading",
|
||||||
},
|
},
|
||||||
ready: {
|
creatingUser: {
|
||||||
on: {
|
invoke: {
|
||||||
GET_USERS: "gettingUsers",
|
src: "createUser",
|
||||||
|
id: "createUser",
|
||||||
|
onDone: {
|
||||||
|
target: "idle",
|
||||||
|
actions: ["displayCreateUserSuccess", "redirectToUsersPage", "clearCreateUserError"],
|
||||||
|
},
|
||||||
|
onError: [
|
||||||
|
{
|
||||||
|
target: "idle",
|
||||||
|
cond: "isFormError",
|
||||||
|
actions: ["assignCreateUserFormErrors"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "idle",
|
||||||
|
actions: ["assignCreateUserError"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
tags: "loading",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
on: {
|
on: {
|
||||||
@ -62,6 +97,10 @@ export const usersMachine = createMachine(
|
|||||||
{
|
{
|
||||||
services: {
|
services: {
|
||||||
getUsers: API.getUsers,
|
getUsers: API.getUsers,
|
||||||
|
createUser: (_, event) => API.createUser(event.user),
|
||||||
|
},
|
||||||
|
guards: {
|
||||||
|
isFormError: (_, event) => isApiError(event.data),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
assignUsers: assign({
|
assignUsers: assign({
|
||||||
@ -75,6 +114,20 @@ export const usersMachine = createMachine(
|
|||||||
...context,
|
...context,
|
||||||
getUsersError: undefined,
|
getUsersError: undefined,
|
||||||
})),
|
})),
|
||||||
|
assignCreateUserError: assign({
|
||||||
|
createUserError: (_, event) => event.data,
|
||||||
|
}),
|
||||||
|
assignCreateUserFormErrors: assign({
|
||||||
|
// the guard ensures it is ApiError
|
||||||
|
createUserFormErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
|
||||||
|
}),
|
||||||
|
clearCreateUserError: assign((context: UsersContext) => ({
|
||||||
|
...context,
|
||||||
|
createUserError: undefined,
|
||||||
|
})),
|
||||||
|
displayCreateUserSuccess: () => {
|
||||||
|
displaySuccess(Language.createUserSuccess)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user