Merged PR 79: 画面実装(ユーザー追加ダイアログ)

## 概要
[Task1596: 画面実装(ユーザー追加ダイアログ)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1596)

- ユーザ一覧画面にユーザ追加ポップアップを追加しました。

## レビューポイント
- 入力エラーチェックは適切か
- Roleを切り替えた際の内容は適切か
- タイピストの選択はデザインのみです。

## UIの変更
- [Task1596](https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task1596?csf=1&web=1&e=iiTOxd)

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-04-26 00:04:48 +00:00
parent 16b7416de0
commit 476c810cc3
13 changed files with 825 additions and 197 deletions

View File

@ -23,4 +23,5 @@ export const errorCodes = [
"E010201", // 未認証ユーザエラー
"E010202", // 認証済ユーザエラー
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
] as const;

View File

@ -0,0 +1,7 @@
export const ROLE = {
AUTHOR: "Author",
TYPIST: "Transcriptionist",
NONE: "None",
} as const;
export type RoleType = typeof ROLE[keyof typeof ROLE];

View File

@ -2,3 +2,4 @@ export * from "./state";
export * from "./operations";
export * from "./selectors";
export * from "./userSlice";
export * from "./constants";

View File

@ -1,6 +1,8 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { UsersApi, GetUsersResponse } from "../../api/api";
import { getTranslationID } from "translation";
import { openSnackbar } from "features/ui/uiSlice";
import { SignupRequest, UsersApi, GetUsersResponse } from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ErrorObject, createErrorObject } from "../../common/errors";
@ -36,3 +38,79 @@ export const listUsersAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const addUserAsync = createAsyncThunk<
{
/* Empty Object */
},
SignupRequest,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("users/addUserAsync", async (args, thunkApi) => {
const {
name,
email,
role,
authorId,
typistGroupId,
autoRenew,
licenseAlert,
notification,
} = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
try {
await usersApi.signup({
name,
email,
role,
authorId,
typistGroupId,
autoRenew,
licenseAlert,
notification,
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("userListPage.message.addUserSuccess"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"z
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E010301") {
errorMessage = getTranslationID(
"signupConfirmPage.message.emailConflictError"
);
}
if (error.code === "E010302") {
errorMessage = getTranslationID(
"userListPage.message.authorIdConflictError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,3 +1,55 @@
import { RootState } from "app/store";
import { ROLE } from "./constants";
export const selectInputValidationErrors = (state: RootState) => {
const { name, email, role, authorId } = state.user.apps.addUser;
// 必須項目のチェック
const hasErrorEmptyName = name === "";
const hasErrorEmptyEmail = email === "";
const hasErrorEmptyAuthorId = role === ROLE.AUTHOR && authorId === "";
const hasErrorIncorrectAuthorId = checkErrorIncorrectAuthorId(
authorId ?? undefined,
role
);
const hasErrorIncorrectEmail = email.match(/^[^@]+@[^@]+$/)?.length !== 1;
return {
hasErrorEmptyName,
hasErrorEmptyEmail,
hasErrorEmptyAuthorId,
hasErrorIncorrectEmail,
hasErrorIncorrectAuthorId,
};
};
export const checkErrorIncorrectAuthorId = (
authorId: string | undefined,
role: string
): boolean => {
if (!authorId || role !== ROLE.AUTHOR) {
return false;
}
// 半角英数字と_の組み合わせで文字まで
const charaTypePattern = /^[A-Z0-9_]{1,16}$/;
const charaType = new RegExp(charaTypePattern).test(authorId);
return !charaType;
};
export const selectName = (state: RootState) => state.user.apps.addUser.name;
export const selectEmail = (state: RootState) => state.user.apps.addUser.email;
export const selectRole = (state: RootState) => state.user.apps.addUser.role;
export const selectAuthorId = (state: RootState) =>
state.user.apps.addUser.authorId;
export const selectTypistGroupId = (state: RootState) =>
state.user.apps.addUser.typistGroupId;
export const selectAutoRenew = (state: RootState) =>
state.user.apps.addUser.autoRenew;
export const selectLicenseAlert = (state: RootState) =>
state.user.apps.addUser.licenseAlert;
export const selectNtotification = (state: RootState) =>
state.user.apps.addUser.notification;
export const selectDomain = (state: RootState) => state.user.domain;

View File

@ -2,8 +2,17 @@ import { User } from "../../api/api";
export interface UsersState {
domain: Domain;
apps: Apps;
}
export interface Domain {
users: User[];
}
export interface Apps {
addUser: AddUser;
}
export interface AddUser extends User {
typistGroupId?: number | undefined;
}

View File

@ -1,15 +1,77 @@
import { createSlice } from "@reduxjs/toolkit";
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UsersState } from "./state";
import { listUsersAsync } from "./operations";
import { ROLE, RoleType } from "./constants";
const initialState: UsersState = {
domain: { users: [] },
apps: {
addUser: {
name: "",
role: ROLE.NONE,
authorId: "",
typistGroupName: "",
email: "",
emailVerified: true,
autoRenew: true,
licenseAlert: true,
notification: true,
},
},
};
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {},
reducers: {
changeName: (state, action: PayloadAction<{ name: string }>) => {
const { name } = action.payload;
state.apps.addUser.name = name;
},
changeEmail: (state, action: PayloadAction<{ email: string }>) => {
const { email } = action.payload;
state.apps.addUser.email = email;
},
changeRole: (state, action: PayloadAction<{ role: RoleType }>) => {
const { role } = action.payload;
state.apps.addUser.role = role;
},
changeAuthorId: (
state,
action: PayloadAction<{ authorId: string | undefined }>
) => {
const { authorId } = action.payload;
state.apps.addUser.authorId = authorId ?? null;
},
changeTypistGroupId: (
state,
action: PayloadAction<{ typistGroupId: number | undefined }>
) => {
const { typistGroupId } = action.payload;
state.apps.addUser.typistGroupId = typistGroupId;
},
changeAutoRenew: (state, action: PayloadAction<{ autoRenew: boolean }>) => {
const { autoRenew } = action.payload;
state.apps.addUser.autoRenew = autoRenew;
},
changeLicenseAlert: (
state,
action: PayloadAction<{ licenseAlert: boolean }>
) => {
const { licenseAlert } = action.payload;
state.apps.addUser.licenseAlert = licenseAlert;
},
changeNotification: (
state,
action: PayloadAction<{ notification: boolean }>
) => {
const { notification } = action.payload;
state.apps.addUser.notification = notification;
},
cleanupAddUser: (state) => {
state.apps.addUser = initialState.apps.addUser;
},
},
extraReducers: (builder) => {
builder.addCase(listUsersAsync.fulfilled, (state, action) => {
state.domain.users = action.payload.users;
@ -17,4 +79,16 @@ export const userSlice = createSlice({
},
});
export const {
changeName,
changeEmail,
changeRole,
changeAuthorId,
changeTypistGroupId,
changeAutoRenew,
changeLicenseAlert,
changeNotification,
cleanupAddUser,
} = userSlice.actions;
export default userSlice.reducer;

View File

@ -1,5 +1,5 @@
import { AppDispatch } from "app/store";
import React, { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import Header from "components/header";
import Footer from "components/footer";
import styles from "styles/app.module.scss";
@ -13,6 +13,7 @@ import deleteImg from "../../assets/images/delete.svg";
import badgeImg from "../../assets/images/badge.svg";
import checkFill from "../../assets/images/check_fill.svg";
import circle from "../../assets/images/circle.svg";
import { UserAddPopup } from "./popup";
// eslintの検査エラー無視設定
/* eslint-disable jsx-a11y/anchor-is-valid */
@ -21,6 +22,12 @@ const UserListPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
const [isPopupOpen, setIsPopupOpen] = useState(false);
const onOpen = useCallback(() => {
setIsPopupOpen(true);
}, [setIsPopupOpen]);
useEffect(() => {
// ユーザ一覧取得処理を呼び出す
dispatch(listUsersAsync());
@ -29,6 +36,13 @@ const UserListPage: React.FC = (): JSX.Element => {
const domain = useSelector(selectDomain);
return (
<>
<UserAddPopup
isOpen={isPopupOpen}
onClose={() => {
setIsPopupOpen(false);
}}
/>
<div className={styles.wrap}>
{/* XXX デザイン上はヘッダに「Account」「User」「License」等の項目が設定されているが、そのままでは使用できない。PBI1128ではユーザ一覧画面は作りこまないので、ユーザ一覧のPBIでヘッダをデザイン通りにする必要がある */}
<Header />
@ -44,10 +58,10 @@ const UserListPage: React.FC = (): JSX.Element => {
<div>
<ul className={styles.menuAction}>
<li>
{/* XXX ユーザ追加のポップアップ対応が必要 */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
href="adminUserAdd.html"
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onOpen}
>
<img src={personAdd} alt="" className={styles.menuIcon} />
{t(getTranslationID("userListPage.label.addUser"))}
@ -94,7 +108,9 @@ const UserListPage: React.FC = (): JSX.Element => {
</th>
<th>
<a className={styles.hasSort}>
{t(getTranslationID("userListPage.label.typistGroup"))}
{t(
getTranslationID("userListPage.label.typistGroup")
)}
</a>
</th>
<th>
@ -215,6 +231,7 @@ const UserListPage: React.FC = (): JSX.Element => {
</main>
<Footer />
</div>
</>
);
};

View File

@ -0,0 +1,341 @@
import { AppDispatch } from "app/store";
import React, { useState, useCallback } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import {
changeEmail,
changeName,
changeRole,
changeAuthorId,
changeTypistGroupId,
changeAutoRenew,
changeLicenseAlert,
changeNotification,
cleanupAddUser,
selectName,
selectEmail,
selectRole,
selectAuthorId,
selectTypistGroupId,
selectInputValidationErrors,
selectAutoRenew,
selectLicenseAlert,
selectNtotification,
addUserAsync,
ROLE,
} from "features/user";
import close from "../../assets/images/close.svg";
interface UserAddPopupProps {
isOpen: boolean;
onClose: () => void;
}
export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
const { isOpen, onClose } = props;
const dispatch: AppDispatch = useDispatch();
const { t } = useTranslation();
const closePopup = useCallback(() => {
setIsPushCreateButton(false);
dispatch(cleanupAddUser());
onClose();
}, [onClose, dispatch]);
const {
hasErrorEmptyName,
hasErrorEmptyEmail,
hasErrorEmptyAuthorId,
hasErrorIncorrectEmail,
hasErrorIncorrectAuthorId,
} = useSelector(selectInputValidationErrors);
const name = useSelector(selectName);
const email = useSelector(selectEmail);
const role = useSelector(selectRole);
const authorId = useSelector(selectAuthorId);
const typistGroupId = useSelector(selectTypistGroupId);
const autoRenew = useSelector(selectAutoRenew);
const licenseAlert = useSelector(selectLicenseAlert);
const notification = useSelector(selectNtotification);
const [isPushCreateButton, setIsPushCreateButton] = useState<boolean>(false);
const onAddUser = useCallback(async () => {
setIsPushCreateButton(true);
if (
hasErrorEmptyName ||
hasErrorEmptyEmail ||
hasErrorEmptyAuthorId ||
hasErrorIncorrectEmail ||
hasErrorIncorrectAuthorId
) {
return;
}
if (role !== ROLE.AUTHOR) {
changeAuthorId({ authorId: undefined });
}
if (role !== ROLE.TYPIST) {
changeTypistGroupId({ typistGroupId: undefined });
}
const { meta } = await dispatch(
addUserAsync({
name,
email,
role,
authorId: role === ROLE.AUTHOR ? authorId ?? undefined : undefined,
typistGroupId: role === ROLE.TYPIST ? typistGroupId : undefined,
autoRenew,
licenseAlert,
notification,
})
);
setIsPushCreateButton(false);
if (meta.requestStatus === "fulfilled") {
closePopup();
}
}, [
dispatch,
closePopup,
hasErrorEmptyName,
hasErrorEmptyEmail,
hasErrorEmptyAuthorId,
hasErrorIncorrectEmail,
hasErrorIncorrectAuthorId,
name,
email,
role,
authorId,
typistGroupId,
autoRenew,
licenseAlert,
notification,
]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("userListPage.label.addUser"))}
<button type="button" onClick={closePopup}>
<img src={close} className={styles.modalTitleIcon} alt="close" />
</button>
</p>
<form className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle}>
{t(getTranslationID("userListPage.label.personal"))}
</dt>
<dt>{t(getTranslationID("userListPage.label.name"))}</dt>
<dd>
<input
type="text"
size={40}
maxLength={255}
className={styles.formInput}
value={name}
onChange={(e) => {
dispatch(changeName({ name: e.target.value }));
}}
/>
{isPushCreateButton && hasErrorEmptyName && (
<span className={styles.formError}>
{t(getTranslationID("signupPage.message.inputEmptyError"))}
</span>
)}
</dd>
<dt>{t(getTranslationID("userListPage.label.email"))}</dt>
<dd>
<input
type="email"
size={40}
maxLength={255}
className={styles.formInput}
value={email}
onChange={(e) => {
dispatch(changeEmail({ email: e.target.value }));
}}
/>
{isPushCreateButton && hasErrorEmptyEmail && (
<span className={styles.formError}>
{t(getTranslationID("signupPage.message.inputEmptyError"))}
</span>
)}
{isPushCreateButton && hasErrorIncorrectEmail && (
<span className={styles.formError}>
{t(
getTranslationID("signupPage.message.emailIncorrectError")
)}
</span>
)}
</dd>
<dt>{t(getTranslationID("userListPage.label.role"))}</dt>
<dd>
<label htmlFor={ROLE.AUTHOR}>
<input
type="radio"
name="role"
className={styles.formRadio}
checked={role === ROLE.AUTHOR}
onChange={() => {
dispatch(changeRole({ role: ROLE.AUTHOR }));
}}
/>
{t(getTranslationID("userListPage.label.author"))}
</label>
<label htmlFor={ROLE.TYPIST}>
<input
type="radio"
name="role"
className={styles.formRadio}
checked={role === ROLE.TYPIST}
onChange={() => {
dispatch(changeRole({ role: ROLE.TYPIST }));
}}
/>
{t(getTranslationID("userListPage.label.transcriptionist"))}
</label>
<label htmlFor="None">
<input
type="radio"
name="role"
className={styles.formRadio}
checked={role === "None"}
onChange={() => {
dispatch(changeRole({ role: "None" }));
}}
/>
{t(getTranslationID("userListPage.label.none"))}
</label>
</dd>
{/** Author 選択時に表示 */}
{role === ROLE.AUTHOR && (
<div className={styles.slideSet} id="">
<dt>{t(getTranslationID("userListPage.label.authorID"))}</dt>
<dd>
<input
type="text"
size={40}
maxLength={16}
className={styles.formInput}
value={authorId ?? undefined}
onChange={(e) => {
dispatch(changeAuthorId({ authorId: e.target.value }));
}}
/>
{isPushCreateButton && hasErrorEmptyAuthorId && (
<span className={styles.formError}>
{t(
getTranslationID("signupPage.message.inputEmptyError")
)}
</span>
)}
{isPushCreateButton && hasErrorIncorrectAuthorId && (
<span className={styles.formError}>
{t(
getTranslationID(
"userListPage.message.authorIdIncorrectError"
)
)}
</span>
)}
</dd>
</div>
)}
{/** Transcriptionist 選択時に表示 */}
{role === ROLE.TYPIST && (
<div className={styles.slideSet} id="">
<dt className={`${styles.overLine} ${styles.marginBtm0}`}>
{t(getTranslationID("userListPage.label.addToGroup"))}
</dt>
<dd>
<select
className={styles.formInput}
onChange={(e) => {
dispatch(
changeTypistGroupId({
typistGroupId: Number(e.target.value),
})
);
}}
>
{/** XXX: タイピストグループは後ほど差し替える */}
<option value={0}>
{t(getTranslationID("userListPage.label.selectGroup"))}
</option>
<option value={1}>Tgroup A</option>
<option value={2}>Tgroup B</option>
<option value={3}>Tgroup C</option>
</select>
</dd>
</div>
)}
<dt>{t(getTranslationID("userListPage.label.setting"))}</dt>
<dd className={styles.last}>
<p>
<label htmlFor="Auto renew">
<input
type="checkbox"
className={styles.formCheck}
checked={autoRenew}
onChange={(e) => {
dispatch(
changeAutoRenew({ autoRenew: e.target.checked })
);
}}
/>
{t(getTranslationID("userListPage.label.autoRenew"))}
</label>
</p>
<p>
<label htmlFor="License Alert">
<input
type="checkbox"
checked={licenseAlert}
className={styles.formCheck}
onChange={(e) => {
dispatch(
changeLicenseAlert({ licenseAlert: e.target.checked })
);
}}
/>
{t(getTranslationID("userListPage.label.licenseAlert"))}
</label>
</p>
<p>
<label htmlFor="Notification">
<input
type="checkbox"
checked={notification}
className={styles.formCheck}
onChange={(e) => {
dispatch(
changeNotification({ notification: e.target.checked })
);
}}
/>
{t(getTranslationID("userListPage.label.notification"))}
</label>
</p>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value="Add user"
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
onClick={onAddUser}
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -95,6 +95,11 @@
}
},
"userListPage": {
"message": {
"addUserSuccess": "(de)メールアドレス宛に認証用メールを送信しました。",
"authorIdConflictError": "(de)このAuthor IDは既に登録されています。他のAuthor IDで登録してください。",
"authorIdIncorrectError": "(de)Author IDの形式が不正です。Author IDは半角英数字(大文字)と\"_\"のみ入力可能です。"
},
"label": {
"title": "(de)User",
"addUser": "(de)Add User",
@ -113,7 +118,14 @@
"licenseAlert": "(de)License alert",
"notification": "(de)Notification",
"users": "(de)users",
"of": "(de)of"
"of": "(de)of",
"personal": "(de)Personal information",
"setting": "(de)Setting",
"selectGroup": "(de)Select group",
"addToGroup": "(de)Add to group (Optional)",
"author": "(de)Author",
"transcriptionist": "(de)Transcriptionist",
"none": "(de)None"
}
}
}

View File

@ -95,6 +95,11 @@
}
},
"userListPage": {
"message": {
"addUserSuccess": "メールアドレス宛に認証用メールを送信しました。",
"authorIdConflictError": "このAuthor IDは既に登録されています。他のAuthor IDで登録してください。",
"authorIdIncorrectError": "Author IDの形式が不正です。Author IDは半角英数字(大文字)と\"_\"のみ入力可能です。"
},
"label": {
"title": "User",
"addUser": "Add User",
@ -113,7 +118,14 @@
"licenseAlert": "License alert",
"notification": "Notification",
"users": "users",
"of": "of"
"of": "of",
"personal": "Personal information",
"setting": "Setting",
"selectGroup": "Select group",
"addToGroup": "Add to group (Optional)",
"author": "Author",
"transcriptionist": "Transcriptionist",
"none": "None"
}
}
}

View File

@ -95,6 +95,11 @@
}
},
"userListPage": {
"message": {
"addUserSuccess": "(es)メールアドレス宛に認証用メールを送信しました。",
"authorIdConflictError": "(es)このAuthor IDは既に登録されています。他のAuthor IDで登録してください。",
"authorIdIncorrectError": "(es)Author IDの形式が不正です。Author IDは半角英数字(大文字)と\"_\"のみ入力可能です。"
},
"label": {
"title": "(es)User",
"addUser": "(es)Add User",
@ -113,7 +118,14 @@
"licenseAlert": "(es)License alert",
"notification": "(es)Notification",
"users": "(es)users",
"of": "(es)of"
"of": "(es)of",
"personal": "(es)Personal information",
"setting": "(es)Setting",
"selectGroup": "(es)Select group",
"addToGroup": "(es)Add to group (Optional)",
"author": "(es)Author",
"transcriptionist": "(es)Transcriptionist",
"none": "(es)None"
}
}
}

View File

@ -95,6 +95,11 @@
}
},
"userListPage": {
"message": {
"addUserSuccess": "(fr)メールアドレス宛に認証用メールを送信しました。",
"authorIdConflictError": "(fr)このAuthor IDは既に登録されています。他のAuthor IDで登録してください。",
"authorIdIncorrectError": "(fr)Author IDの形式が不正です。Author IDは半角英数字(大文字)と\"_\"のみ入力可能です。"
},
"label": {
"title": "(fr)User",
"addUser": "(fr)Add User",
@ -113,7 +118,14 @@
"licenseAlert": "(fr)License alert",
"notification": "(fr)Notification",
"users": "(fr)users",
"of": "(fr)of"
"of": "(fr)of",
"personal": "(fr)Personal information",
"setting": "(fr)Setting",
"selectGroup": "(fr)Select group",
"addToGroup": "(fr)Add to group (Optional)",
"author": "(fr)Author",
"transcriptionist": "(fr)Transcriptionist",
"none": "(fr)None"
}
}
}