Merged PR 348: 画面実装(TypistGroup追加ポップアップ&TypistGroup設定画面)

## 概要
[Task2424: 画面実装(TypistGroup追加ポップアップ&TypistGroup設定画面)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2424)

- TypistGroup追加ポップアップを追加しました。
  - 言語対応を実装しています。
  - 入力エラー時のメッセージ表示を実装しています。

## レビューポイント
- エラーメッセージの出し方は問題ないか
- デザイン適用は適切か

## UIの変更
- [Task2424](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/Task2424?csf=1&web=1&e=IVxM43)

## 動作確認状況
- ローカルで確認
※実際のAPIを呼んでの登録は未検証です
This commit is contained in:
makabe.t 2023-08-25 05:33:05 +00:00
parent 9f2f163141
commit 15d7119306
13 changed files with 4575 additions and 5558 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,12 @@ import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { openSnackbar } from "features/ui/uiSlice";
import { getTranslationID } from "translation";
import { AccountsApi, GetTypistGroupsResponse } from "../../../api/api";
import {
AccountsApi,
GetTypistGroupsResponse,
GetTypistsResponse,
CreateTypistGroupRequest,
} from "../../../api/api";
import { Configuration } from "../../../api/configuration";
import { ErrorObject, createErrorObject } from "../../../common/errors";
@ -15,7 +20,7 @@ export const listTypistGroupsAsync = createAsyncThunk<
error: ErrorObject;
};
}
>("dictations/listTypistGroupsAsync", async (args, thunkApi) => {
>("workflow/listTypistGroupsAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
@ -41,3 +46,88 @@ export const listTypistGroupsAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistsAsync = createAsyncThunk<
GetTypistsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/listTypistsAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
const typists = await accountsApi.getTypists({
headers: { authorization: `Bearer ${accessToken}` },
});
return typists.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const createTypistGroupAsync = createAsyncThunk<
{
/* Empty Object */
},
CreateTypistGroupRequest,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/createTypistGroupAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
await accountsApi.createTypistGroup(args, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
const message =
error.statusCode === 400
? getTranslationID("typistGroupSetting.message.groupAddFailedError")
: getTranslationID("common.message.internalServerError");
thunkApi.dispatch(
openSnackbar({
level: "error",
message,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -5,3 +5,25 @@ export const selectTypistGroups = (state: RootState) =>
export const selectIsLoading = (state: RootState) =>
state.typistGroup.apps.isLoading;
export const selectTypists = (state: RootState) =>
state.typistGroup.domain.typists;
export const selectPoolTypists = (state: RootState) =>
state.typistGroup.domain.typists.filter(
(t) => !state.typistGroup.apps.selectedTypists.some((x) => t.id === x.id)
);
export const selectSelectedTypists = (state: RootState) =>
state.typistGroup.apps.selectedTypists;
export const selectGroupName = (state: RootState) =>
state.typistGroup.apps.groupName;
export const selectAddGroupErrors = (state: RootState) => {
const hasErrorEmptyGroupName = state.typistGroup.apps.groupName === "";
const hasErrorSelectedTypistsEmpty =
state.typistGroup.apps.selectedTypists.length === 0;
return { hasErrorEmptyGroupName, hasErrorSelectedTypistsEmpty };
};

View File

@ -1,4 +1,4 @@
import { TypistGroup } from "../../../api/api";
import { Typist, TypistGroup } from "../../../api/api";
export interface TypistGroupState {
apps: Apps;
@ -7,8 +7,11 @@ export interface TypistGroupState {
export interface Apps {
isLoading: boolean;
selectedTypists: Typist[];
groupName: string;
}
export interface Domain {
typistGroups: TypistGroup[];
typists: Typist[];
}

View File

@ -1,20 +1,54 @@
import { createSlice } from "@reduxjs/toolkit";
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { Typist } from "api";
import { TypistGroupState } from "./state";
import { listTypistGroupsAsync } from "./operations";
import { listTypistGroupsAsync, listTypistsAsync } from "./operations";
const initialState: TypistGroupState = {
apps: {
isLoading: false,
selectedTypists: [],
groupName: "",
},
domain: {
typistGroups: [],
typists: [],
},
};
export const typistGroupSlice = createSlice({
name: "typistGroup",
initialState,
reducers: {},
reducers: {
cleanupAddCroup: (state) => {
state.apps.groupName = "";
state.apps.selectedTypists = [];
},
addSelectedTypist: (state, action: PayloadAction<{ typist: Typist }>) => {
const { typist } = action.payload;
const selectedTypists = [...state.apps.selectedTypists, typist];
if (!state.apps.selectedTypists.find((x) => x.id === typist.id)) {
state.apps.selectedTypists = selectedTypists.sort(
(a, b) => a.id - b.id
);
}
},
removeSelectedTypist: (
state,
action: PayloadAction<{ typist: Typist }>
) => {
const { typist } = action.payload;
const selectedTypists = state.apps.selectedTypists.filter(
(x) => x.id !== typist.id
);
state.apps.selectedTypists = selectedTypists.sort((a, b) => a.id - b.id);
},
changeGroupName: (state, action: PayloadAction<{ groupName: string }>) => {
const { groupName } = action.payload;
state.apps.groupName = groupName;
},
},
extraReducers: (builder) => {
builder.addCase(listTypistGroupsAsync.pending, (state) => {
state.apps.isLoading = true;
@ -26,7 +60,24 @@ export const typistGroupSlice = createSlice({
builder.addCase(listTypistGroupsAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(listTypistsAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(listTypistsAsync.fulfilled, (state, action) => {
state.domain.typists = action.payload.typists;
state.apps.isLoading = false;
});
builder.addCase(listTypistsAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export const {
cleanupAddCroup,
addSelectedTypist,
removeSelectedTypist,
changeGroupName,
} = typistGroupSlice.actions;
export default typistGroupSlice.reducer;

View File

@ -0,0 +1,203 @@
import React, { useCallback, useEffect, useState } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { Typist } from "api";
import { AppDispatch } from "app/store";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import {
addSelectedTypist,
listTypistsAsync,
cleanupAddCroup,
removeSelectedTypist,
selectPoolTypists,
selectSelectedTypists,
selectGroupName,
changeGroupName,
selectAddGroupErrors,
} from "features/workflow/typistGroup";
import {
createTypistGroupAsync,
listTypistGroupsAsync,
} from "features/workflow/typistGroup/operations";
import { openSnackbar } from "features/ui";
import close from "../../assets/images/close.svg";
interface AddTypistGroupPopupProps {
onClose: (isChanged: boolean) => void;
isOpen: boolean;
}
export const AddTypistGroupPopup: React.FC<AddTypistGroupPopupProps> = (
props
) => {
const { onClose, isOpen } = props;
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
const [isPushAddButton, setIsPushAddButton] = useState<boolean>(false);
const poolTypists = useSelector(selectPoolTypists);
const selectedTypists = useSelector(selectSelectedTypists);
const groupName = useSelector(selectGroupName);
const { hasErrorEmptyGroupName, hasErrorSelectedTypistsEmpty } =
useSelector(selectAddGroupErrors);
// 開閉時のみ実行
useEffect(() => {
if (isOpen) {
dispatch(cleanupAddCroup());
dispatch(listTypistsAsync());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
setIsPushAddButton(false);
onClose(false);
}, [onClose]);
// グループ追加を実行
const addTypistGroup = useCallback(async () => {
setIsPushAddButton(true);
// 入力チェック
if (hasErrorEmptyGroupName || hasErrorSelectedTypistsEmpty) {
if (hasErrorSelectedTypistsEmpty) {
dispatch(
openSnackbar({
level: "error",
message: t(
getTranslationID(
"typistGroupSetting.message.selectedTypistEmptyError"
)
),
})
);
}
return;
}
// グループ追加APIを実行
const { meta } = await dispatch(
createTypistGroupAsync({
typistGroupName: groupName,
typistIds: selectedTypists.map((typist) => typist.id),
})
);
setIsPushAddButton(false);
if (meta.requestStatus === "fulfilled") {
closePopup();
dispatch(listTypistGroupsAsync());
}
}, [
t,
closePopup,
dispatch,
groupName,
selectedTypists,
hasErrorEmptyGroupName,
hasErrorSelectedTypistsEmpty,
]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("typistGroupSetting.label.addTypistGroup"))}
<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} />
<dt>{t(getTranslationID("typistGroupSetting.label.groupName"))}</dt>
<dd>
<input
type="text"
size={40}
maxLength={50}
className={styles.formInput}
value={groupName}
onChange={(e) =>
dispatch(changeGroupName({ groupName: e.target.value }))
}
/>
{isPushAddButton && hasErrorEmptyGroupName && (
<span className={styles.formError}>
{t(getTranslationID("common.message.inputEmptyError"))}
</span>
)}
</dd>
<dt className={styles.formTitle}>
{t(getTranslationID("typistGroupSetting.label.transcriptionist"))}
</dt>
<dd className={`${styles.formChange} ${styles.last}`}>
<ul className={styles.chooseMember}>
<li className={styles.changeTitle}>
{t(getTranslationID("typistGroupSetting.label.selected"))}
</li>
{selectedTypists.map((typist: Typist) => (
<li key={typist.id}>
<input
type="checkbox"
className={styles.formCheck}
value={typist.name}
id={`${typist.id}`}
checked
onClick={() => dispatch(removeSelectedTypist({ typist }))}
/>
<label
htmlFor={`${typist.id}`}
title={t(
getTranslationID("typistGroupSetting.label.remove")
)}
>
{typist.name}
</label>
</li>
))}
</ul>
<p />
<ul className={styles.holdMember}>
<li className={styles.changeTitle}>
{t(getTranslationID("typistGroupSetting.label.pool"))}
</li>
{poolTypists.map((typist: Typist) => (
<li key={typist.id}>
<input
type="checkbox"
className={styles.formCheck}
value={typist.name}
id={`${typist.id}`}
onClick={() => dispatch(addSelectedTypist({ typist }))}
/>
<label
htmlFor={`${typist.id}`}
title={t(
getTranslationID("typistGroupSetting.label.add")
)}
>
{typist.name}
</label>
</li>
))}
</ul>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(getTranslationID("typistGroupSetting.label.addGroup"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
onClick={addTypistGroup}
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -1,4 +1,4 @@
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";
@ -15,6 +15,7 @@ import {
import { AppDispatch } from "app/store";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
import { AddTypistGroupPopup } from "./addTypistGroupPopup";
const TypistGroupSettingPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
@ -23,95 +24,114 @@ const TypistGroupSettingPage: React.FC = (): JSX.Element => {
const isLoading = useSelector(selectIsLoading);
const typistGroup = useSelector(selectTypistGroups);
const [isAddPopupOpen, setIsAddPopupOpen] = useState(false);
const onAddPopupOpen = useCallback(() => {
// typist一覧を取得
setIsAddPopupOpen(true);
}, [setIsAddPopupOpen]);
useEffect(() => {
dispatch(listTypistGroupsAsync());
}, [dispatch]);
return (
<div className={styles.wrap}>
<Header userName="XXXXXX" />
<UpdateTokenTimer />
<main className={styles.main}>
<div>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>
{t(getTranslationID("workflowPage.label.title"))}
</h1>
<p className={styles.pageTx}>{` ${t(
getTranslationID("typistGroupSetting.label.title")
)}`}</p>
</div>
<section className={styles.workflow}>
<div>
<ul className={styles.menuAction}>
<li>
<a
href="/workflow"
className={`${styles.menuLink} ${styles.isActive}`}
>
<img src={undo} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.return"))}
</a>
</li>
<li>
<a className={`${styles.menuLink} ${styles.isActive}`}>
<img src={group_add} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.addGroup"))}
</a>
</li>
</ul>
<table className={`${styles.table} ${styles.group}`}>
<tr className={styles.tableHeader}>
<th className={styles.noLine}>
{t(getTranslationID("typistGroupSetting.label.groupName"))}
</th>
<th>{/** empty th */}</th>
</tr>
{!isLoading && typistGroup.length === 0 ? (
<p style={{ margin: "10px", textAlign: "center" }}>
{t(getTranslationID("common.message.listEmpty"))}
</p>
) : (
typistGroup.map((group) => (
<tr key={group.id}>
<td>{group.name}</td>
<td>
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
className={`${styles.menuLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"typistGroupSetting.label.edit"
)
)}
</a>
</li>
</ul>
</td>
</tr>
))
)}
</table>
{isLoading && (
<img
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
)}
<>
<AddTypistGroupPopup
onClose={() => {
setIsAddPopupOpen(false);
}}
isOpen={isAddPopupOpen}
/>
<div className={styles.wrap}>
<Header userName="XXXXXX" />
<UpdateTokenTimer />
<main className={styles.main}>
<div>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>
{t(getTranslationID("workflowPage.label.title"))}
</h1>
<p className={styles.pageTx}>{` ${t(
getTranslationID("typistGroupSetting.label.title")
)}`}</p>
</div>
</section>
</div>
</main>
<Footer />
</div>
<section className={styles.workflow}>
<div>
<ul className={styles.menuAction}>
<li>
<a
href="/workflow"
className={`${styles.menuLink} ${styles.isActive}`}
>
<img src={undo} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.return"))}
</a>
</li>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onAddPopupOpen}
>
<img src={group_add} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.addGroup"))}
</a>
</li>
</ul>
<table className={`${styles.table} ${styles.group}`}>
<tr className={styles.tableHeader}>
<th className={styles.noLine}>
{t(
getTranslationID("typistGroupSetting.label.groupName")
)}
</th>
<th>{/** empty th */}</th>
</tr>
{!isLoading && typistGroup.length === 0 ? (
<p style={{ margin: "10px", textAlign: "center" }}>
{t(getTranslationID("common.message.listEmpty"))}
</p>
) : (
typistGroup.map((group) => (
<tr key={group.id}>
<td>{group.name}</td>
<td>
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
className={`${styles.menuLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"typistGroupSetting.label.edit"
)
)}
</a>
</li>
</ul>
</td>
</tr>
))
)}
</table>
{isLoading && (
<img
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
)}
</div>
</section>
</div>
</main>
<Footer />
</div>
</>
);
};

View File

@ -1,7 +1,7 @@
{
"common": {
"message": {
"inputEmptyError": "(de)Error Message",
"inputEmptyError": "(de)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(de)Error Message",
"emailIncorrectError": "(de)Error Message",
"internalServerError": "(de)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
@ -365,7 +365,17 @@
"return": "(de)Return",
"addGroup": "(de)Add Group",
"groupName": "(de)Group Name",
"edit": "(de)Edit"
"edit": "(de)Edit",
"addTypistGroup": "(de)Add Transcriptionist Group",
"transcriptionist": "(de)Transcriptionist",
"selected": "(de)Selected",
"pool": "(de)Pool",
"add": "(de)Add",
"remove": "(de)Remove"
},
"message": {
"selectedTypistEmptyError": "(de)TranscriptionistGroupには最低1名のメンバーが必要です。",
"groupAddFailedError": "(de)TypistGroupの追加に失敗しました。画面を更新し、再度実行してください"
}
}
}

View File

@ -1,7 +1,7 @@
{
"common": {
"message": {
"inputEmptyError": "Error Message",
"inputEmptyError": "この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "Error Message",
"emailIncorrectError": "Error Message",
"internalServerError": "処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
@ -365,7 +365,17 @@
"return": "Return",
"addGroup": "Add Group",
"groupName": "Group Name",
"edit": "Edit"
"edit": "Edit",
"addTypistGroup": "Add Transcriptionist Group",
"transcriptionist": "Transcriptionist",
"selected": "Selected",
"pool": "Pool",
"add": "Add",
"remove": "Remove"
},
"message": {
"selectedTypistEmptyError": "TranscriptionistGroupには最低1名のメンバーが必要です。",
"groupAddFailedError": "TypistGroupの追加に失敗しました。画面を更新し、再度実行してください"
}
}
}

View File

@ -1,7 +1,7 @@
{
"common": {
"message": {
"inputEmptyError": "(es)Error Message",
"inputEmptyError": "(es)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(es)Error Message",
"emailIncorrectError": "(es)Error Message",
"internalServerError": "(es)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
@ -365,7 +365,17 @@
"return": "(es)Return",
"addGroup": "(es)Add Group",
"groupName": "(es)Group Name",
"edit": "(es)Edit"
"edit": "(es)Edit",
"addTypistGroup": "(es)Add Transcriptionist Group",
"transcriptionist": "(es)Transcriptionist",
"selected": "(es)Selected",
"pool": "(es)Pool",
"add": "(es)Add",
"remove": "(es)Remove"
},
"message": {
"selectedTypistEmptyError": "(es)TranscriptionistGroupには最低1名のメンバーが必要です。",
"groupAddFailedError": "(es)TypistGroupの追加に失敗しました。画面を更新し、再度実行してください"
}
}
}

View File

@ -1,7 +1,7 @@
{
"common": {
"message": {
"inputEmptyError": "(fr)Error Message",
"inputEmptyError": "(fr)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(fr)Error Message",
"emailIncorrectError": "(fr)Error Message",
"internalServerError": "(fr)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
@ -365,7 +365,17 @@
"return": "(fr)Return",
"addGroup": "(fr)Add Group",
"groupName": "(fr)Group Name",
"edit": "(fr)Edit"
"edit": "(fr)Edit",
"addTypistGroup": "(fr)Add Transcriptionist Group",
"transcriptionist": "(fr)Transcriptionist",
"selected": "(fr)Selected",
"pool": "(fr)Pool",
"add": "(fr)Add",
"remove": "(fr)Remove"
},
"message": {
"selectedTypistEmptyError": "(fr)TranscriptionistGroupには最低1名のメンバーが必要です。",
"groupAddFailedError": "(fr)TypistGroupの追加に失敗しました。画面を更新し、再度実行してください"
}
}
}

View File

@ -2293,7 +2293,7 @@
"typistIds": {
"minItems": 1,
"type": "array",
"items": { "type": "string" }
"items": { "type": "integer" }
}
},
"required": ["typistGroupName", "typistIds"]

View File

@ -8,6 +8,7 @@ import {
Min,
ArrayMinSize,
MinLength,
IsArray,
} from 'class-validator';
import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator';
@ -136,8 +137,9 @@ export class CreateTypistGroupRequest {
@MinLength(1)
@MaxLength(50)
typistGroupName: string;
@ApiProperty({ minItems: 1 })
@ApiProperty({ minItems: 1, isArray: true, type: 'integer' })
@ArrayMinSize(1)
@IsArray()
typistIds: number[];
}