From 7682c41ba57db973dc817c4de2dbf936de5d9a03 Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Tue, 10 Oct 2023 00:57:11 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20462:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E8=BF=BD=E5=8A=A0=E3=83=9D?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=A2=E3=83=83=E3=83=97=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2740: ワークフロー追加ポップアップ実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2740) - ワークフロー追加Popupを実装 - 多言語対応 ## レビューポイント - タイピスト・タイピストグループの変更処理 - 選択・除外で処理を分けたが一緒にしたほうが良い? - 各パラメータの変更処理 - 値のチェックは足りているか ## UIの変更 - 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/Task2740?csf=1&web=1&e=UHGFtv ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- dictation_client/src/common/errors/code.ts | 1 + .../src/features/workflow/operations.ts | 173 +++++++++- .../src/features/workflow/selectors.ts | 45 +++ .../src/features/workflow/state.ts | 15 +- .../src/features/workflow/workflowSlice.ts | 116 ++++++- .../pages/WorkflowPage/addworkflowPopup.tsx | 293 +++++++++++++++++ .../src/pages/WorkflowPage/index.tsx | 295 ++++++++++-------- dictation_client/src/translation/de.json | 17 +- dictation_client/src/translation/en.json | 17 +- dictation_client/src/translation/es.json | 17 +- dictation_client/src/translation/fr.json | 17 +- 11 files changed, 861 insertions(+), 145 deletions(-) create mode 100644 dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx diff --git a/dictation_client/src/common/errors/code.ts b/dictation_client/src/common/errors/code.ts index 7d1b632..41368a5 100644 --- a/dictation_client/src/common/errors/code.ts +++ b/dictation_client/src/common/errors/code.ts @@ -55,4 +55,5 @@ export const errorCodes = [ "E011001", // ワークタイプ重複エラー "E011002", // ワークタイプ登録上限超過エラー "E011003", // ワークタイプ不在エラー + "E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー ] as const; diff --git a/dictation_client/src/features/workflow/operations.ts b/dictation_client/src/features/workflow/operations.ts index 41957aa..76e84a5 100644 --- a/dictation_client/src/features/workflow/operations.ts +++ b/dictation_client/src/features/workflow/operations.ts @@ -1,9 +1,22 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Configuration, GetWorkflowsResponse, WorkflowsApi } from "api"; +import { + AccountsApi, + Author, + Configuration, + GetWorkflowsResponse, + TemplateFile, + TemplatesApi, + Typist, + TypistGroup, + WorkflowTypist, + WorkflowsApi, + Worktype, +} from "api"; import type { RootState } from "app/store"; import { ErrorObject, createErrorObject } from "common/errors"; import { openSnackbar } from "features/ui/uiSlice"; import { getTranslationID } from "translation"; +import { WorkflowRelations } from "./state"; export const listWorkflowAsync = createAsyncThunk< GetWorkflowsResponse, @@ -40,3 +53,161 @@ export const listWorkflowAsync = createAsyncThunk< return thunkApi.rejectWithValue({ error }); } }); + +export const createWorkflowAsync = createAsyncThunk< + { + /* Empty Object */ + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/createWorkflowAsync", async (args, thunkApi) => { + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const workflowsApi = new WorkflowsApi(config); + const { selectedAssignees, authorId, templateId, worktypeId } = + state.workflow.apps; + + try { + if (authorId === undefined) { + throw new Error("authorId is not found"); + } + // 選択されたタイピストを取得し、リクエスト用の型に変換する + const typists = selectedAssignees.map( + (item): WorkflowTypist => ({ + typistId: item.typistUserId, + typistGroupId: item.typistGroupId, + }) + ); + await workflowsApi.createWorkflows( + { + authorId, + typists, + templateId, + worktypeId, + }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + const { code, statusCode } = error; + // AuthorIDとWorktypeIDが一致するものが既に存在する場合 + if (code === "E013001") { + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID( + "workflowPage.message.workflowConflictError" + ), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + // パラメータが存在しない場合 + if (statusCode === 400) { + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("workflowPage.message.saveFailedError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + // その他のエラー + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + +export const getworkflowRelationsAsync = createAsyncThunk< + { + authors: Author[]; + typists: Typist[]; + typistGroups: TypistGroup[]; + templates: TemplateFile[]; + worktypes: Worktype[]; + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/getworkflowRelationsAsync", 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); + const templatesApi = new TemplatesApi(config); + + try { + const { authors } = ( + await accountsApi.getAuthors({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { typists } = ( + await accountsApi.getTypists({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { typistGroups } = ( + await accountsApi.getTypistGroups({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { templates } = ( + await templatesApi.getTemplates({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { worktypes } = ( + await accountsApi.getWorktypes({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + + return { + authors, + typists, + typistGroups, + templates, + worktypes, + }; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/workflow/selectors.ts b/dictation_client/src/features/workflow/selectors.ts index 57745ed..a71ba4a 100644 --- a/dictation_client/src/features/workflow/selectors.ts +++ b/dictation_client/src/features/workflow/selectors.ts @@ -1,3 +1,4 @@ +import { Assignee } from "api"; import { RootState } from "app/store"; export const selectWorkflows = (state: RootState) => @@ -5,3 +6,47 @@ export const selectWorkflows = (state: RootState) => export const selectIsLoading = (state: RootState) => state.workflow.apps.isLoading; + +export const selectWorkflowRelations = (state: RootState) => + state.workflow.domain.workflowRelations; + +export const selectWorkflowAssinee = (state: RootState) => { + // 選択されたassigneeを取得 + const { selectedAssignees } = state.workflow.apps; + // すべてのassigneeを取得 + const assignees = state.workflow.domain.workflowRelations?.assignees ?? []; + // assigneeが選択されているかどうかを判定する + const isAssigneeSelected = (assignee: Assignee) => + selectedAssignees.some( + (sa) => + sa.typistUserId === assignee.typistUserId && + sa.typistGroupId === assignee.typistGroupId + ); + // 未選択のassigneeを取得する + const poolAssignees = assignees.filter( + (assignee) => !isAssigneeSelected(assignee) + ); + // selectedAssigneesとpoolAssigneesをtypistNameでソートして返す + return { + selectedAssignees: [...selectedAssignees].sort((a, b) => + a.typistName.localeCompare(b.typistName) + ), + poolAssignees: poolAssignees.sort((a, b) => + a.typistName.localeCompare(b.typistName) + ), + }; +}; +export const selectIsAddLoading = (state: RootState) => + state.workflow.apps.isAddLoading; + +export const selectWorkflowError = (state: RootState) => { + // authorIdがundefinedの場合はエラーを返す + const hasAuthorIdEmptyError = state.workflow.apps.authorId === undefined; + // workflowAssineeのselectedが空の場合はエラーを返す + const hasSelectedWorkflowAssineeEmptyError = + state.workflow.apps.selectedAssignees.length === 0; + return { + hasAuthorIdEmptyError, + hasSelectedWorkflowAssineeEmptyError, + }; +}; diff --git a/dictation_client/src/features/workflow/state.ts b/dictation_client/src/features/workflow/state.ts index 6a9d838..e99a186 100644 --- a/dictation_client/src/features/workflow/state.ts +++ b/dictation_client/src/features/workflow/state.ts @@ -1,4 +1,4 @@ -import { Workflow } from "api"; +import { Assignee, Author, TemplateFile, Workflow, Worktype } from "api"; export interface WorkflowState { apps: Apps; @@ -7,8 +7,21 @@ export interface WorkflowState { export interface Apps { isLoading: boolean; + isAddLoading: boolean; + selectedAssignees: Assignee[]; + authorId?: number; + worktypeId?: number; + templateId?: number; } export interface Domain { workflows?: Workflow[]; + workflowRelations?: WorkflowRelations; +} + +export interface WorkflowRelations { + authors: Author[]; + assignees: Assignee[]; + templates: TemplateFile[]; + worktypes: Worktype[]; } diff --git a/dictation_client/src/features/workflow/workflowSlice.ts b/dictation_client/src/features/workflow/workflowSlice.ts index dd94a79..bb6aa9f 100644 --- a/dictation_client/src/features/workflow/workflowSlice.ts +++ b/dictation_client/src/features/workflow/workflowSlice.ts @@ -1,10 +1,17 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Assignee } from "api"; +import { + createWorkflowAsync, + getworkflowRelationsAsync, + listWorkflowAsync, +} from "./operations"; import { WorkflowState } from "./state"; -import { listWorkflowAsync } from "./operations"; const initialState: WorkflowState = { apps: { isLoading: false, + isAddLoading: false, + selectedAssignees: [], }, domain: {}, }; @@ -12,7 +19,55 @@ const initialState: WorkflowState = { export const workflowSlice = createSlice({ name: "workflow", initialState, - reducers: {}, + reducers: { + clearWorkflow: (state) => { + state.apps.selectedAssignees = []; + state.apps.authorId = undefined; + state.apps.worktypeId = undefined; + state.apps.templateId = undefined; + state.domain.workflowRelations = undefined; + }, + addAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => { + const { assignee } = action.payload; + const { selectedAssignees } = state.apps; + + // assigneeがselectedAssigneesに存在するか確認する + const isDuplicate = selectedAssignees.some( + (x) => + x.typistUserId === assignee.typistUserId && + x.typistGroupId === assignee.typistGroupId + ); + + // 重複していなければ追加する + if (!isDuplicate) { + const newSelectedAssignees = [...selectedAssignees, assignee]; + // stateに保存する + state.apps.selectedAssignees = newSelectedAssignees; + } + }, + removeAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => { + const { assignee } = action.payload; + const { selectedAssignees } = state.apps; + // selectedAssigneeの要素からassigneeを削除する + state.apps.selectedAssignees = selectedAssignees.filter( + (x) => + x.typistUserId !== assignee.typistUserId || + x.typistGroupId !== assignee.typistGroupId + ); + }, + changeAuthor: (state, action: PayloadAction<{ authorId: number }>) => { + const { authorId } = action.payload; + state.apps.authorId = authorId; + }, + changeWorktype: (state, action: PayloadAction<{ worktypeId?: number }>) => { + const { worktypeId } = action.payload; + state.apps.worktypeId = worktypeId; + }, + changeTemplate: (state, action: PayloadAction<{ templateId?: number }>) => { + const { templateId } = action.payload; + state.apps.templateId = templateId; + }, + }, extraReducers: (builder) => { builder.addCase(listWorkflowAsync.pending, (state) => { state.apps.isLoading = true; @@ -26,7 +81,62 @@ export const workflowSlice = createSlice({ builder.addCase(listWorkflowAsync.rejected, (state) => { state.apps.isLoading = false; }); + builder.addCase(getworkflowRelationsAsync.pending, (state) => { + state.apps.isAddLoading = true; + }); + builder.addCase(getworkflowRelationsAsync.fulfilled, (state, action) => { + const { authors, typistGroups, typists, templates, worktypes } = + action.payload; + + // 取得したtypistsとtypistGroupsを型変換 + const assineeTypists = typists.map( + (typist): Assignee => ({ + typistUserId: typist.id, + typistGroupId: undefined, + typistName: typist.name, + }) + ); + const assineeTypistGroups = typistGroups.map( + (typistGroup): Assignee => ({ + typistUserId: undefined, + typistGroupId: typistGroup.id, + typistName: typistGroup.name, + }) + ); + // 取得したtypistsとtypistGroupsを結合 + const assinees = [...assineeTypists, ...assineeTypistGroups]; + // storeに保存 + state.domain.workflowRelations = { + authors, + assignees: assinees, + templates, + worktypes, + }; + + state.apps.isAddLoading = false; + }); + builder.addCase(getworkflowRelationsAsync.rejected, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(createWorkflowAsync.pending, (state) => { + state.apps.isAddLoading = true; + }); + builder.addCase(createWorkflowAsync.fulfilled, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(createWorkflowAsync.rejected, (state) => { + state.apps.isAddLoading = false; + }); }, }); +export const { + addAssignee, + removeAssignee, + changeAuthor, + changeWorktype, + changeTemplate, + clearWorkflow, +} = workflowSlice.actions; + export default workflowSlice.reducer; diff --git a/dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx b/dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx new file mode 100644 index 0000000..0192cd3 --- /dev/null +++ b/dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx @@ -0,0 +1,293 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { AppDispatch } from "app/store"; +import progress_activit from "assets/images/progress_activit.svg"; +import { + addAssignee, + removeAssignee, + changeAuthor, + changeTemplate, + changeWorktype, + clearWorkflow, + selectIsAddLoading, + selectWorkflowAssinee, + selectWorkflowError, + selectWorkflowRelations, +} from "features/workflow"; +import { + createWorkflowAsync, + getworkflowRelationsAsync, + listWorkflowAsync, +} from "features/workflow/operations"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import styles from "styles/app.module.scss"; +import { getTranslationID } from "translation"; +import close from "../../assets/images/close.svg"; + +interface AddWorkflowPopupProps { + onClose: () => void; +} + +export const AddWorkflowPopup: React.FC = ( + props +): JSX.Element => { + const { onClose } = props; + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + // 保存ボタンを押したかどうか + const [isPushAddButton, setIsPushAddButton] = useState(false); + + const workflowRelations = useSelector(selectWorkflowRelations); + const { poolAssignees, selectedAssignees } = useSelector( + selectWorkflowAssinee + ); + const isLoading = useSelector(selectIsAddLoading); + const { hasAuthorIdEmptyError, hasSelectedWorkflowAssineeEmptyError } = + useSelector(selectWorkflowError); + useEffect(() => { + dispatch(getworkflowRelationsAsync()); + // ポップアップのアンマウント時に初期化を行う + return () => { + dispatch(clearWorkflow()); + setIsPushAddButton(false); + }; + }, [dispatch]); + + const changeWorktypeId = useCallback( + (target: string) => { + // 空文字の場合はundefinedをdispatchする + if (target === "") { + dispatch(changeWorktype({ worktypeId: undefined })); + } else if (!Number.isNaN(Number(target))) { + dispatch(changeWorktype({ worktypeId: Number(target) })); + } + }, + [dispatch] + ); + + const changeTemplateId = useCallback( + (target: string) => { + // 空文字の場合はundefinedをdispatchする + if (target === "") { + dispatch(changeTemplate({ templateId: undefined })); + } else if (!Number.isNaN(Number(target))) { + dispatch(changeTemplate({ templateId: Number(target) })); + } + }, + [dispatch] + ); + + const changeAuthorId = useCallback( + (target: string) => { + if (!Number.isNaN(target)) { + dispatch(changeAuthor({ authorId: Number(target) })); + } + }, + [dispatch] + ); + + // 追加ボタン押下時の処理 + const handleAdd = useCallback(async () => { + setIsPushAddButton(true); + // エラーチェック + if (hasAuthorIdEmptyError || hasSelectedWorkflowAssineeEmptyError) { + return; + } + const { meta } = await dispatch(createWorkflowAsync()); + if (meta.requestStatus === "fulfilled") { + onClose(); + dispatch(listWorkflowAsync()); + } + }, [ + dispatch, + hasAuthorIdEmptyError, + hasSelectedWorkflowAssineeEmptyError, + onClose, + ]); + + return ( +
+
+

+ {t(getTranslationID("worktypeIdSetting.label.addWorktypeId"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + close +

+
+
+
+
{t(getTranslationID("workflowPage.label.authorID"))}
+
+ + {isPushAddButton && hasAuthorIdEmptyError && ( + + {t(getTranslationID("workflowPage.message.inputEmptyError"))} + + )} +
+
+ {t(getTranslationID("workflowPage.label.worktypeOptional"))} +
+
+ +
+
+ {t(getTranslationID("typistGroupSetting.label.transcriptionist"))} +
+
+
    +
  • + {t(getTranslationID("workflowPage.label.selected"))} +
  • + {selectedAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( +
  • + { + dispatch(removeAssignee({ assignee: x })); + }} + /> + +
  • + ); + })} +
+

+

    +
  • + {t(getTranslationID("workflowPage.label.pool"))} +
  • + {poolAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( +
  • + dispatch(addAssignee({ assignee: x }))} + /> + +
  • + ); + })} +
+ {isPushAddButton && hasSelectedWorkflowAssineeEmptyError && ( + + {t( + getTranslationID( + "workflowPage.message.selectedTypistEmptyError" + ) + )} + + )} +
+
+ {t(getTranslationID("workflowPage.label.templateOptional"))} +
+
+ +
+
+ + {isLoading && ( + Loading + )} +
+
+
+
+
+ ); +}; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index c7e8351..355891c 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import Header from "components/header"; import Footer from "components/footer"; import styles from "styles/app.module.scss"; @@ -14,10 +14,13 @@ import { listWorkflowAsync } from "features/workflow/operations"; import { selectIsLoading, selectWorkflows } from "features/workflow"; import progress_activit from "assets/images/progress_activit.svg"; import { getTranslationID } from "translation"; +import { AddWorkflowPopup } from "./addworkflowPopup"; const WorkflowPage: React.FC = (): JSX.Element => { const dispatch: AppDispatch = useDispatch(); const [t] = useTranslation(); + // 追加Popupの表示制御 + const [isShowAddPopup, setIsShowAddPopup] = useState(false); const workflows = useSelector(selectWorkflows); const isLoading = useSelector(selectIsLoading); @@ -25,138 +28,166 @@ const WorkflowPage: React.FC = (): JSX.Element => { dispatch(listWorkflowAsync()); }, [dispatch]); return ( -
-
- -
-
-
-

- {t(getTranslationID("workflowPage.label.title"))} -

-
-
-
- - - - - - - - - - {workflows?.map((workflow) => ( - - - - - - - - - ))} -
{/** empty th */}{t(getTranslationID("workflowPage.label.authorID"))}{t(getTranslationID("workflowPage.label.worktype"))} - {t(getTranslationID("workflowPage.label.transcriptionist"))} - {t(getTranslationID("workflowPage.label.template"))}
- - {workflow.author.authorId}{workflow.worktype?.worktypeId ?? "-"} - {workflow.typists.map((typist, i) => ( - <> - {typist.typistName} - {i !== workflow.typists.length - 1 &&
} - - ))} -
{workflow.template?.fileName ?? "-"}
- {!isLoading && workflows?.length === 0 && ( -

- {t(getTranslationID("common.message.listEmpty"))} -

- )} - {isLoading && ( - Loading - )} + <> + {isShowAddPopup && ( + { + setIsShowAddPopup(false); + }} + /> + )} +
+
+ +
+
+
+

+ {t(getTranslationID("workflowPage.label.title"))} +

-
-
-
-
-
+
+
+ + + + + + + + + + {workflows?.map((workflow) => ( + + + + + + + + + ))} +
{/** empty th */} + {t(getTranslationID("workflowPage.label.authorID"))} + + {t(getTranslationID("workflowPage.label.worktype"))} + + {t( + getTranslationID("workflowPage.label.transcriptionist") + )} + + {t(getTranslationID("workflowPage.label.template"))} +
+ + {workflow.author.authorId}{workflow.worktype?.worktypeId ?? "-"} + {workflow.typists.map((typist, i) => ( + <> + {typist.typistName} + {i !== workflow.typists.length - 1 &&
} + + ))} +
{workflow.template?.fileName ?? "-"}
+ {!isLoading && workflows?.length === 0 && ( +

+ {t(getTranslationID("common.message.listEmpty"))} +

+ )} + {isLoading && ( + Loading + )} +
+
+ + +