Skip to content
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

feat(oh2-299): new user form #621

Merged
merged 13 commits into from
Jul 24, 2024
32 changes: 29 additions & 3 deletions src/components/accessories/admin/users/Users.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";

import { Tabs, Tab } from "@material-ui/core";

import Button from "../../button/Button";
import UsersTable from "./usersTable";
import UserGroupsTable from "./userGroupsTable";

import { PATHS } from "../../../../consts";

export const Users = () => {
const navigate = useNavigate();
const { t } = useTranslation();

const [tab, setTab] = useState<"users" | "groups">("users");
return (
<>
Expand All @@ -13,10 +22,27 @@ export const Users = () => {
onChange={(_, value) => setTab(value)}
aria-label="switch between users and groups"
>
<Tab label="Users" value="users" />
<Tab label="Groups" value="groups" />
<Tab label={t("user.users")} value="users" />
<Tab label={t("user.groups")} value="groups" />
</Tabs>
{tab === "users" ? <UsersTable /> : <UserGroupsTable />}
{tab === "users" ? (
<UsersTable
headerActions={
<Button
onClick={() => {
navigate(PATHS.admin_users_new);
}}
type="button"
variant="contained"
color="primary"
>
{t("user.addUser")}
</Button>
}
/>
) : (
<UserGroupsTable />
)}
</>
);
};
3 changes: 2 additions & 1 deletion src/components/accessories/admin/users/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./Users";
export { Users } from "./Users";
export { NewUser } from "./newUser";
201 changes: 201 additions & 0 deletions src/components/accessories/admin/users/newUser/NewUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React, { useEffect } from "react";
import { useFormik } from "formik";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@material-ui/lab";
import {
TextField as MuiTextField,
FormControl,
FormHelperText,
} from "@material-ui/core";
import TextField from "../../../textField/TextField";
import Button from "../../../button/Button";
import ConfirmationDialog from "../../../confirmationDialog/ConfirmationDialog";
import checkIcon from "../../../../../assets/check-icon.png";

import { IState } from "../../../../../types";
import { ApiResponse } from "../../../../../state/types";
import { UserDTO, UserGroupDTO } from "../../../../../generated";

import { userSchema } from "./validation";
import "./styles.scss";
import {
createUser,
createUserReset,
} from "../../../../../state/users/actions";
import { getUserGroups } from "../../../../../state/usergroups/actions";
import { PATHS } from "../../../../../consts";

const initialValues = {
userName: "",
userGroupName: { code: "" },
desc: "",
passwd: "",
};

export const NewUser = () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();

const create = useSelector<IState, ApiResponse<UserDTO>>(
(state) => state.users.create
);

const userGroupsTypeState = useSelector(
(state: IState) => state.usergroups.groupList
);

const {
handleSubmit,
handleBlur,
setFieldValue,
getFieldProps,
setFieldTouched,
isValid,
dirty,
resetForm,
errors,
touched,
values,
} = useFormik({
initialValues,
validationSchema: userSchema(t),
onSubmit: (values: UserDTO) => {
dispatch(createUser(values));
},
});

useEffect(() => {
dispatch(getUserGroups());
return () => {
dispatch(createUserReset());
};
}, [dispatch]);

return (
<div className="newUserForm">
<form className="newUserForm__form" onSubmit={handleSubmit}>
<div className="row start-sm center-xs">
<div className="newUserForm__item fullWidth">
<FormControl variant="outlined" className="autocomplete">
<Autocomplete
id="userGroupName"
options={[
...(userGroupsTypeState.data ?? []),
{ code: "", desc: "(none)" },
]}
value={values.userGroupName}
disabled={userGroupsTypeState.isLoading || create.isLoading}
onBlur={() => setFieldTouched("userGroupName")}
onChange={(_ev: any, value: UserGroupDTO | null) => {
setFieldValue("userGroupName", value);
}}
renderInput={(params) => (
<MuiTextField
{...params}
name="userGroupName"
variant="outlined"
size="small"
error={!!(touched.userGroupName && errors.userGroupName)}
fullWidth
label={t("user.group")}
/>
)}
getOptionLabel={(option: UserGroupDTO) =>
option.desc ?? option.code ?? "no option code"
}
getOptionSelected={(a, b) => a.code === b.code}
/>
{touched.userGroupName && errors.userGroupName && (
<FormHelperText error>
{errors.userGroupName?.code || errors.userGroupName}
</FormHelperText>
)}
</FormControl>
</div>
<div className="newUserForm__item fullWidth">
<TextField
field={getFieldProps("userName")}
theme="regular"
label={t("user.username")}
isValid={!!touched.userName && !!errors.userName}
errorText={(touched.userName && errors.userName) || ""}
onBlur={handleBlur}
type="text"
/>
</div>
<div className="newUserForm__item fullWidth">
<TextField
field={getFieldProps("passwd")}
theme="regular"
label={t("user.password")}
isValid={!!touched.passwd && !!errors.passwd}
errorText={(touched.passwd && errors.passwd) || ""}
onBlur={handleBlur}
type="password"
// this below prevents from saving the password on the computer
InputProps={{ autoComplete: "one-time-code" }}
/>
</div>
<div className="newUserForm__item fullWidth">
<TextField
field={getFieldProps("desc")}
theme="regular"
label={t("user.description")}
isValid={!!touched.desc && !!errors.desc}
errorText={(touched.desc && errors.desc) || ""}
onBlur={handleBlur}
/>
</div>
</div>
<div className="newUserForm__buttonSet">
<div className="submit_button">
<Button
type="submit"
variant="contained"
disabled={!!create.isLoading || !isValid || !dirty}
>
{t("common.save")}
</Button>
</div>
<div className="reset_button">
<Button
type="reset"
variant="text"
disabled={!!create.isLoading || !dirty}
onClick={async () => {
resetForm();
}}
>
{t("common.reset")}
</Button>
</div>
</div>
<ConfirmationDialog
isOpen={create.hasSucceeded}
title={t("user.createdSuccessTitle")}
icon={checkIcon}
info={t("user.createdSuccessMessage")}
primaryButtonLabel="Ok"
handlePrimaryButtonClick={() => {
navigate(PATHS.admin_users);
}}
handleSecondaryButtonClick={() => ({})}
/>
<ConfirmationDialog
isOpen={create.hasFailed}
title={t("errors.internalerror")}
icon={checkIcon}
info={create.error?.toString()}
primaryButtonLabel="Ok"
handlePrimaryButtonClick={() => {
navigate(PATHS.admin_users);
}}
handleSecondaryButtonClick={() => ({})}
/>
</form>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/accessories/admin/users/newUser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NewUser } from "./NewUser";
84 changes: 84 additions & 0 deletions src/components/accessories/admin/users/newUser/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
@import "../../../../../styles/variables";
@import "../../../../../../node_modules/susy/sass/susy";

.newUserForm {
display: inline-block;
flex-direction: column;
align-items: center;
width: 100%;

.formInsertMode {
margin: 0px 0px 20px;
}

.row {
justify-content: space-between;
}

.newUserForm__item {
margin: 7px 0px;
padding: 0px 15px;
width: 50%;
@include susy-media($narrow) {
padding: 0px 10px;
}
@include susy-media($tablet_land) {
padding: 0px 10px;
}
@include susy-media($medium-up) {
width: 25%;
}
@include susy-media($tablet_port) {
width: 50%;
}
@include susy-media($smartphone) {
width: 100%;
}
.textField,
.selectField {
width: 100%;
}

&.fullWidth {
width: 100%;
}

&.halfWidth {
width: 50%;
@include susy-media($smartphone) {
width: 100%;
}
}
&.thirdWidth {
width: 33%;
@include susy-media($smartphone) {
width: 100%;
}
}
}

.newUserForm__buttonSet {
display: flex;
margin-top: 25px;
padding: 0px 15px;
flex-direction: row-reverse;
@include susy-media($smartphone_small) {
display: block;
}

.submit_button,
.reset_button {
.MuiButton-label {
font-size: smaller;
letter-spacing: 1px;
font-weight: 600;
}
button {
@include susy-media($smartphone_small) {
width: 100%;
margin-top: 10px;
}
}
}
}
}
19 changes: 19 additions & 0 deletions src/components/accessories/admin/users/newUser/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { passwordRules } from "./validation";

describe("password rules", () => {
it("should pass when all rules are matched", () => {
expect(passwordRules.test("ThisPassw0rdIsCorrect"));
});
it("should be 5 characters long", () => {
expect(passwordRules.test("aA4")).toBeFalsy();
});
it("should contain an uppercase", () => {
expect(passwordRules.test("thispassw0rdisnotcorrect")).toBeFalsy();
});
it("should contain a lowercase", () => {
expect(passwordRules.test("THISPASSW0RDISNOTCORRECT")).toBeFalsy();
});
it("should contain a number", () => {
expect(passwordRules.test("ThisPasswordIsNotCorrect")).toBeFalsy();
});
});
24 changes: 24 additions & 0 deletions src/components/accessories/admin/users/newUser/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { object, string } from "yup";
import { UserDTO, UserGroupDTO } from "../../../../../generated";
import { TFunction } from "react-i18next";

// min 5 characters, 1 upper case letter, 1 lower case letter, 1 numeric digit.
export const passwordRules = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{5,}$/;

export const userSchema = (t: TFunction<"translation">) =>
object().shape<UserDTO>({
userName: string().min(2).required(t("user.validateUserName")),
userGroupName: object<UserGroupDTO>({
code: string().required(t("user.validateUserNeedsGroup")),
desc: string(),
})
.nullable()
.required(t("user.validateUserNeedsGroup")),
passwd: string()
.required(t("user.validatePasswordNeeded"))
.min(5, t("user.validatePasswordTooShort"))
.matches(passwordRules, {
message: t("user.validatePasswordTooWeak"),
}),
desc: string(),
});
Loading