Merged PR 479: ワークフロー更新ポップアップ実装

## 概要
[Task2777: ワークフロー更新ポップアップ実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2777)

- ワークフロー編集ポップアップを実装しました。

## レビューポイント
- 表示内容は適切か
- 選択ワークフローの値取得処理は適切か

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

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2023-10-12 07:42:53 +00:00
parent 49bd0e5ffe
commit 2d569aee6d
10 changed files with 529 additions and 10 deletions

View File

@ -140,6 +140,105 @@ export const createWorkflowAsync = createAsyncThunk<
}
});
export const updateWorkflowAsync = createAsyncThunk<
{
/* Empty Object */
},
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/updateWorkflowAsync", 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 {
selectedWorkflow,
selectedAssignees,
authorId,
templateId,
worktypeId,
} = state.workflow.apps;
try {
if (selectedWorkflow === undefined) {
throw new Error("selectedWorkflow is not found");
}
if (authorId === undefined) {
throw new Error("authorId is not found");
}
// 選択されたタイピストを取得し、リクエスト用の型に変換する
const typists = selectedAssignees.map(
(item): WorkflowTypist => ({
typistId: item.typistUserId,
typistGroupId: item.typistGroupId,
})
);
await workflowsApi.updateWorkflow(
selectedWorkflow.id,
{
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;
if (statusCode === 400) {
// AuthorIDとWorktypeIDが一致するものが既に存在する場合
if (code === "E013001") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"workflowPage.message.workflowConflictError"
),
})
);
return thunkApi.rejectWithValue({ error });
}
// パラメータが存在しない場合
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[];

View File

@ -36,6 +36,15 @@ export const selectWorkflowAssinee = (state: RootState) => {
),
};
};
export const selectSelectedWorkflow = (state: RootState) =>
state.workflow.apps.selectedWorkflow;
export const selectAuthorId = (state: RootState) =>
state.workflow.apps.authorId;
export const selectWorktypeId = (state: RootState) =>
state.workflow.apps.worktypeId;
export const selectTemplateId = (state: RootState) =>
state.workflow.apps.templateId;
export const selectIsAddLoading = (state: RootState) =>
state.workflow.apps.isAddLoading;

View File

@ -12,6 +12,7 @@ export interface Apps {
authorId?: number;
worktypeId?: number;
templateId?: number;
selectedWorkflow?: Workflow;
}
export interface Domain {

View File

@ -5,6 +5,7 @@ import {
getworkflowRelationsAsync,
deleteWorkflowAsync,
listWorkflowAsync,
updateWorkflowAsync,
} from "./operations";
import { WorkflowState } from "./state";
@ -22,12 +23,17 @@ export const workflowSlice = createSlice({
initialState,
reducers: {
clearWorkflow: (state) => {
state.apps.selectedWorkflow = undefined;
state.apps.selectedAssignees = [];
state.apps.authorId = undefined;
state.apps.worktypeId = undefined;
state.apps.templateId = undefined;
state.domain.workflowRelations = undefined;
},
setAssignees: (state, action: PayloadAction<{ assignees: Assignee[] }>) => {
const { assignees } = action.payload;
state.apps.selectedAssignees = assignees;
},
addAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => {
const { assignee } = action.payload;
const { selectedAssignees } = state.apps;
@ -56,18 +62,35 @@ export const workflowSlice = createSlice({
x.typistGroupId !== assignee.typistGroupId
);
},
changeAuthor: (state, action: PayloadAction<{ authorId: number }>) => {
changeAuthor: (
state,
action: PayloadAction<{ authorId?: number | undefined }>
) => {
const { authorId } = action.payload;
state.apps.authorId = authorId;
},
changeWorktype: (state, action: PayloadAction<{ worktypeId?: number }>) => {
changeWorktype: (
state,
action: PayloadAction<{ worktypeId?: number | undefined }>
) => {
const { worktypeId } = action.payload;
state.apps.worktypeId = worktypeId;
},
changeTemplate: (state, action: PayloadAction<{ templateId?: number }>) => {
changeTemplate: (
state,
action: PayloadAction<{ templateId?: number | undefined }>
) => {
const { templateId } = action.payload;
state.apps.templateId = templateId;
},
changeSelectedWorkflow: (
state,
action: PayloadAction<{ workflowId: number }>
) => {
const { workflowId } = action.payload;
const workflow = state.domain.workflows?.find((x) => x.id === workflowId);
state.apps.selectedWorkflow = workflow;
},
},
extraReducers: (builder) => {
builder.addCase(listWorkflowAsync.pending, (state) => {
@ -128,6 +151,15 @@ export const workflowSlice = createSlice({
builder.addCase(createWorkflowAsync.rejected, (state) => {
state.apps.isAddLoading = false;
});
builder.addCase(updateWorkflowAsync.pending, (state) => {
state.apps.isAddLoading = true;
});
builder.addCase(updateWorkflowAsync.fulfilled, (state) => {
state.apps.isAddLoading = false;
});
builder.addCase(updateWorkflowAsync.rejected, (state) => {
state.apps.isAddLoading = false;
});
builder.addCase(deleteWorkflowAsync.pending, (state) => {
state.apps.isLoading = true;
});
@ -141,11 +173,13 @@ export const workflowSlice = createSlice({
});
export const {
setAssignees,
addAssignee,
removeAssignee,
changeAuthor,
changeWorktype,
changeTemplate,
changeSelectedWorkflow,
clearWorkflow,
} = workflowSlice.actions;

View File

@ -0,0 +1,313 @@
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,
selectSelectedWorkflow,
selectAuthorId,
selectWorktypeId,
setAssignees,
selectTemplateId,
} from "features/workflow";
import {
getworkflowRelationsAsync,
listWorkflowAsync,
updateWorkflowAsync,
} 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 EditWorkflowPopupProps {
onClose: () => void;
}
export const EditWorkflowPopup: React.FC<EditWorkflowPopupProps> = (
props
): JSX.Element => {
const { onClose } = props;
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
// 保存ボタンを押したかどうか
const [isPushEditButton, setIsPushEditButton] = useState<boolean>(false);
const workflow = useSelector(selectSelectedWorkflow);
const authorId = useSelector(selectAuthorId);
const worktypeId = useSelector(selectWorktypeId);
const templateId = useSelector(selectTemplateId);
const workflowRelations = useSelector(selectWorkflowRelations);
const { poolAssignees, selectedAssignees } = useSelector(
selectWorkflowAssinee
);
const isLoading = useSelector(selectIsAddLoading);
const { hasAuthorIdEmptyError, hasSelectedWorkflowAssineeEmptyError } =
useSelector(selectWorkflowError);
useEffect(() => {
dispatch(getworkflowRelationsAsync());
// ポップアップのアンマウント時に初期化を行う
return () => {
dispatch(clearWorkflow());
setIsPushEditButton(false);
};
}, [dispatch]);
useEffect(() => {
dispatch(changeAuthor({ authorId: workflow?.author?.id }));
dispatch(changeWorktype({ worktypeId: workflow?.worktype?.id }));
dispatch(changeTemplate({ templateId: workflow?.template?.id }));
dispatch(setAssignees({ assignees: workflow?.typists ?? [] }));
}, [dispatch, workflow]);
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 handleSave = useCallback(async () => {
setIsPushEditButton(true);
// エラーチェック
if (hasAuthorIdEmptyError || hasSelectedWorkflowAssineeEmptyError) {
return;
}
const { meta } = await dispatch(updateWorkflowAsync());
if (meta.requestStatus === "fulfilled") {
onClose();
dispatch(listWorkflowAsync());
}
}, [
dispatch,
hasAuthorIdEmptyError,
hasSelectedWorkflowAssineeEmptyError,
onClose,
]);
return (
<div className={`${styles.modal} ${styles.isShow}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("workflowPage.label.editRoutingRule"))}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<img
src={close}
className={styles.modalTitleIcon}
alt="close"
onClick={onClose}
/>
</p>
<form className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dt>{t(getTranslationID("workflowPage.label.authorID"))}</dt>
<dd>
<select
className={styles.formInput}
value={authorId}
onChange={(e) => {
changeAuthorId(e.target.value);
}}
>
<option value="" hidden>
{`-- ${t(
getTranslationID("workflowPage.label.selectAuthor")
)} --`}
</option>
{workflowRelations?.authors.map((author) => (
<option key={author.authorId} value={author.id}>
{author.authorId}
</option>
))}
</select>
{isPushEditButton && hasAuthorIdEmptyError && (
<span className={styles.formError}>
{t(getTranslationID("workflowPage.message.inputEmptyError"))}
</span>
)}
</dd>
<dt className={styles.overLine}>
{t(getTranslationID("workflowPage.label.worktypeOptional"))}
</dt>
<dd>
<select
className={styles.formInput}
value={worktypeId}
onChange={(e) => {
changeWorktypeId(e.target.value);
}}
>
<option value="" hidden>
{`-- ${t(
getTranslationID("workflowPage.label.selectWorktypeId")
)} --`}
</option>
<option value="">
{`-- ${t(getTranslationID("common.label.notSelected"))} --`}
</option>
{workflowRelations?.worktypes.map((worktype) => (
<option key={worktype.id} value={worktype.id}>
{worktype.worktypeId}
</option>
))}
</select>
</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("workflowPage.label.selected"))}
</li>
{selectedAssignees?.map((x) => {
const key = `${x.typistName}_${
x.typistUserId ?? x.typistGroupId
}`;
return (
<li key={key}>
<input
type="checkbox"
className={styles.formCheck}
value={x.typistName}
id={key}
checked
onClick={() => {
dispatch(removeAssignee({ assignee: x }));
}}
/>
<label htmlFor={key} title="Remove">
{x.typistName}
</label>
</li>
);
})}
</ul>
<p />
<ul className={styles.holdMember}>
<li className={styles.changeTitle}>
{t(getTranslationID("workflowPage.label.pool"))}
</li>
{poolAssignees?.map((x) => {
const key = `${x.typistName}_${
x.typistUserId ?? x.typistGroupId
}`;
return (
<li key={key}>
<input
type="checkbox"
className={styles.formCheck}
value={x.typistName}
id={key}
onClick={() => dispatch(addAssignee({ assignee: x }))}
/>
<label htmlFor={key} title="Add">
{x.typistName}
</label>
</li>
);
})}
</ul>
{isPushEditButton && hasSelectedWorkflowAssineeEmptyError && (
<span
className={styles.formError}
style={{ margin: "0px 30px 0px 30px" }}
>
{t(
getTranslationID(
"workflowPage.message.selectedTypistEmptyError"
)
)}
</span>
)}
</dd>
<dt className={styles.overLine}>
{t(getTranslationID("workflowPage.label.templateOptional"))}
</dt>
<dd className={styles.last}>
<select
className={styles.formInput}
value={templateId}
onChange={(e) => {
changeTemplateId(e.target.value);
}}
>
<option value="" hidden>
{`-- ${t(
getTranslationID("workflowPage.label.selectTemplate")
)} --`}
</option>
<option value="">
{`-- ${t(getTranslationID("common.label.notSelected"))} --`}
</option>
{workflowRelations?.templates.map((template) => (
<option
key={`${template.name}_${template.id}`}
value={template.id}
>
{template.name}
</option>
))}
</select>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
value={t(getTranslationID("common.label.save"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isLoading ? styles.isActive : ""
}`}
onClick={handleSave}
/>
{isLoading && (
<img
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
)}
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -13,17 +13,23 @@ import { useDispatch, useSelector } from "react-redux";
import {
deleteWorkflowAsync,
listWorkflowAsync,
} from "features/workflow/operations";
import { selectIsLoading, selectWorkflows } from "features/workflow";
changeSelectedWorkflow,
selectIsLoading,
selectWorkflows,
} from "features/workflow";
import progress_activit from "assets/images/progress_activit.svg";
import { getTranslationID } from "translation";
import { AddWorkflowPopup } from "./addworkflowPopup";
import { EditWorkflowPopup } from "./editworkflowPopup";
const WorkflowPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
// 追加Popupの表示制御
const [isShowAddPopup, setIsShowAddPopup] = useState<boolean>(false);
// 編集Popupの表示制御
const [isShowEditPopup, setIsShowEditPopup] = useState<boolean>(false);
const workflows = useSelector(selectWorkflows);
const isLoading = useSelector(selectIsLoading);
@ -57,6 +63,13 @@ const WorkflowPage: React.FC = (): JSX.Element => {
}}
/>
)}
{isShowEditPopup && (
<EditWorkflowPopup
onClose={() => {
setIsShowEditPopup(false);
}}
/>
)}
<div className={styles.wrap}>
<Header userName="XXXXXX" />
<UpdateTokenTimer />
@ -157,7 +170,17 @@ const WorkflowPage: React.FC = (): JSX.Element => {
<td className={styles.clm0}>
<ul className={styles.menuInTable}>
<li>
<a href="">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
onClick={() => {
dispatch(
changeSelectedWorkflow({
workflowId: workflow.id,
})
);
setIsShowEditPopup(true);
}}
>
{t(
getTranslationID("workflowPage.label.editRule")
)}

View File

@ -358,6 +358,7 @@
"label": {
"title": "Arbeitsablauf",
"addRoutingRule": "(de)Add Routing Rule",
"editRoutingRule": "(de)Edit Routing Rule",
"templateSetting": "(de)Template Setting",
"worktypeIdSetting": "(de)WorktypeID Setting",
"typistGroupSetting": "(de)Transcriptionist Group Setting",
@ -497,5 +498,14 @@
"message": "(de)Your account has been deleted. Thank you for using our services.",
"backToTopPageLink": "(de)Back to TOP Page"
}
},
"AgreeToUsePage": {
"label": {
"title": "(de)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(de)Click here to read the terms of use.",
"linkOfDpa": "(de)Click here to read the terms of use.",
"checkBoxForConsent": "(de)Yes, I agree to the terms of use.",
"forOdds": "(de)for ODDS."
}
}
}

View File

@ -358,6 +358,7 @@
"label": {
"title": "Workflow",
"addRoutingRule": "Add Routing Rule",
"editRoutingRule": "Edit Routing Rule",
"templateSetting": "Template Setting",
"worktypeIdSetting": "WorktypeID Setting",
"typistGroupSetting": "Transcriptionist Group Setting",
@ -497,5 +498,14 @@
"message": "Your account has been deleted. Thank you for using our services.",
"backToTopPageLink": "Back to TOP Page"
}
},
"AgreeToUsePage": {
"label": {
"title": "Terms of Use has updated. Please confirm again.",
"linkOfEula": "Click here to read the terms of use.",
"linkOfDpa": "Click here to read the terms of use.",
"checkBoxForConsent": "Yes, I agree to the terms of use.",
"forOdds": "for ODDS."
}
}
}

View File

@ -358,6 +358,7 @@
"label": {
"title": "flujo de trabajo",
"addRoutingRule": "(es)Add Routing Rule",
"editRoutingRule": "(es)Edit Routing Rule",
"templateSetting": "(es)Template Setting",
"worktypeIdSetting": "(es)WorktypeID Setting",
"typistGroupSetting": "(es)Transcriptionist Group Setting",
@ -497,5 +498,14 @@
"message": "(es)Your account has been deleted. Thank you for using our services.",
"backToTopPageLink": "(es)Back to TOP Page"
}
},
"AgreeToUsePage": {
"label": {
"title": "(es)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(es)Click here to read the terms of use.",
"linkOfDpa": "(es)Click here to read the terms of use.",
"checkBoxForConsent": "(es)Yes, I agree to the terms of use.",
"forOdds": "(es)for ODDS."
}
}
}

View File

@ -358,6 +358,7 @@
"label": {
"title": "Flux de travail",
"addRoutingRule": "(fr)Add Routing Rule",
"editRoutingRule": "(fr)Edit Routing Rule",
"templateSetting": "(fr)Template Setting",
"worktypeIdSetting": "(fr)WorktypeID Setting",
"typistGroupSetting": "(fr)Transcriptionist Group Setting",
@ -497,5 +498,14 @@
"message": "(fr)Your account has been deleted. Thank you for using our services.",
"backToTopPageLink": "(fr)Back to TOP Page"
}
},
"AgreeToUsePage": {
"label": {
"title": "(fr)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(fr)Click here to read the terms of use.",
"linkOfDpa": "(fr)Click here to read the terms of use.",
"checkBoxForConsent": "(fr)Yes, I agree to the terms of use.",
"forOdds": "(fr)for ODDS."
}
}
}