Merged PR 202: 画面実装(PlayBackボタン)

## 概要
[Task1997: 画面実装(PlayBackボタン)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1997)

- Playbackボタン押下時の挙動を実装
  - typist
    - 自身が割り当て候補となっているタスクをPlayBackする
    - 成功時、カスタムURLスキームでデスクトップアプリを起動する
  - author
    - 自身のAuthorIDと一致するタスクをPlayBackする
    - 成功時、カスタムURLスキームでデスクトップアプリを起動する
- ログイン時の、カスタムURLスキームを実際のデスクトップアプリのスキーム名に修正

## レビューポイント
- playbackAsyncのなかでソート条件更新APIを一緒に呼び出しているが問題ないか
  - ソート条件を更新するタイミングはここで問題ないか
  - ユーザーがTypistの時のみ更新するようにしたが問題ないか

## 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/Task1997?csf=1&web=1&e=9kLaxo

## 動作確認状況
- ローカルで確認

## 補足
- Authorの挙動はAPI側の実装が完了していないので、未確認
This commit is contained in:
saito.k 2023-07-04 06:06:37 +00:00
parent d6db89bc2c
commit 3a7bf60f3e
18 changed files with 233 additions and 99 deletions

View File

@ -2,3 +2,4 @@ VITE_STAGE=develop
VITE_B2C_CLIENTID=5eb34cba-84b6-46f9-a0ea-bc5c41157d63
VITE_B2C_AUTHORITY=https://adb2codmsdev.b2clogin.com/adb2codmsdev.onmicrosoft.com/b2c_1_signin_dev
VITE_B2C_KNOWNAUTHORITIES=adb2codmsdev.b2clogin.com
VITE_DESK_TOP_APP_SCHEME=odms-desktopapp

View File

@ -2,3 +2,4 @@ VITE_STAGE=local
VITE_B2C_CLIENTID=XXXX-XXXX-XXXXX-XXXX
VITE_B2C_AUTHORITY=https://adb2XXXX.XXXX.com/adb2XXXX.onmicrosoft.com/XXXX
VITE_B2C_KNOWNAUTHORITIES=adb2cXXXX.XXXx.com
VITE_DESK_TOP_APP_SCHEME=odms-desktopapp

View File

@ -2,3 +2,4 @@ VITE_STAGE=staging
VITE_B2C_CLIENTID=5d8f0db9-4506-41d6-a5bb-5ec39f6eba8d
VITE_B2C_AUTHORITY=https://adb2codmsstg.b2clogin.com/adb2codmsstg.onmicrosoft.com/b2c_1_signin_stg
VITE_B2C_KNOWNAUTHORITIES=adb2codmsstg.b2clogin.com
VITE_DESK_TOP_APP_SCHEME=odms-desktopapp

View File

@ -2408,7 +2408,7 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration
/**
*
* @summary
* @param {number} audioFileId
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {PostCheckoutPermissionRequest} postCheckoutPermissionRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
@ -2736,7 +2736,7 @@ export const TasksApiFp = function(configuration?: Configuration) {
/**
*
* @summary
* @param {number} audioFileId
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {PostCheckoutPermissionRequest} postCheckoutPermissionRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
@ -2848,7 +2848,7 @@ export const TasksApiFactory = function (configuration?: Configuration, basePath
/**
*
* @summary
* @param {number} audioFileId
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {PostCheckoutPermissionRequest} postCheckoutPermissionRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
@ -2957,7 +2957,7 @@ export class TasksApi extends BaseAPI {
/**
*
* @summary
* @param {number} audioFileId
* @param {number} audioFileId ODMS Cloud上の音声ファイルID
* @param {PostCheckoutPermissionRequest} postCheckoutPermissionRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
@ -3163,7 +3163,7 @@ export const UsersApiAxiosParamCreator = function (configuration?: Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSortCcriteria: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getSortCriteria: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/users/sort-criteria`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -3272,9 +3272,9 @@ export const UsersApiAxiosParamCreator = function (configuration?: Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateSortCcriteria: async (postSortCriteriaRequest: PostSortCriteriaRequest, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
updateSortCriteria: async (postSortCriteriaRequest: PostSortCriteriaRequest, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'postSortCriteriaRequest' is not null or undefined
assertParamExists('updateSortCcriteria', 'postSortCriteriaRequest', postSortCriteriaRequest)
assertParamExists('updateSortCriteria', 'postSortCriteriaRequest', postSortCriteriaRequest)
const localVarPath = `/users/sort-criteria`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -3353,8 +3353,8 @@ export const UsersApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSortCcriteria(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<GetSortCriteriaResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSortCcriteria(options);
async getSortCriteria(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<GetSortCriteriaResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSortCriteria(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -3385,8 +3385,8 @@ export const UsersApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateSortCcriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateSortCcriteria(postSortCriteriaRequest, options);
async updateSortCriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateSortCriteria(postSortCriteriaRequest, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@ -3434,8 +3434,8 @@ export const UsersApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSortCcriteria(options?: any): AxiosPromise<GetSortCriteriaResponse> {
return localVarFp.getSortCcriteria(options).then((request) => request(axios, basePath));
getSortCriteria(options?: any): AxiosPromise<GetSortCriteriaResponse> {
return localVarFp.getSortCriteria(options).then((request) => request(axios, basePath));
},
/**
*
@ -3463,8 +3463,8 @@ export const UsersApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateSortCcriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: any): AxiosPromise<object> {
return localVarFp.updateSortCcriteria(postSortCriteriaRequest, options).then((request) => request(axios, basePath));
updateSortCriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: any): AxiosPromise<object> {
return localVarFp.updateSortCriteria(postSortCriteriaRequest, options).then((request) => request(axios, basePath));
},
};
};
@ -3518,8 +3518,8 @@ export class UsersApi extends BaseAPI {
* @throws {RequiredError}
* @memberof UsersApi
*/
public getSortCcriteria(options?: AxiosRequestConfig) {
return UsersApiFp(this.configuration).getSortCcriteria(options).then((request) => request(this.axios, this.basePath));
public getSortCriteria(options?: AxiosRequestConfig) {
return UsersApiFp(this.configuration).getSortCriteria(options).then((request) => request(this.axios, this.basePath));
}
/**
@ -3553,8 +3553,8 @@ export class UsersApi extends BaseAPI {
* @throws {RequiredError}
* @memberof UsersApi
*/
public updateSortCcriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: AxiosRequestConfig) {
return UsersApiFp(this.configuration).updateSortCcriteria(postSortCriteriaRequest, options).then((request) => request(this.axios, this.basePath));
public updateSortCriteria(postSortCriteriaRequest: PostSortCriteriaRequest, options?: AxiosRequestConfig) {
return UsersApiFp(this.configuration).updateSortCriteria(postSortCriteriaRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View File

@ -25,5 +25,6 @@ export const errorCodes = [
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
"E010401", // PONumber重複エラー
"E010601", // タスク変更不可エラー
"E010601", // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
"E010602", // タスク変更権限不足エラー
] as const;

View File

@ -79,3 +79,16 @@ export const isAuthorUser = (): boolean => {
}
return token.role.includes(USER_ROLES.AUTHOR);
};
/**
* is author user Authorかどうかを返す
* @returns bool
*/
export const isTypistUser = (): boolean => {
const jwt = loadAccessToken();
const token = jwt ? decodeToken(jwt) : null;
if (!token) {
return false;
}
return token.role.includes(USER_ROLES.TYPIST);
};

View File

@ -6,6 +6,7 @@ import {
listTasksAsync,
listTypistGroupsAsync,
listTypistsAsync,
playbackAsync,
updateAssigneeAsync,
} from "./operations";
import {
@ -131,6 +132,15 @@ export const dictationSlice = createSlice({
builder.addCase(updateAssigneeAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(playbackAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(playbackAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(playbackAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -94,7 +94,7 @@ export const getSortColumnAsync = createAsyncThunk<
const usersApi = new UsersApi(config);
try {
const sort = await usersApi.getSortCcriteria({
const sort = await usersApi.getSortCriteria({
headers: { authorization: `Bearer ${accessToken}` },
});
@ -125,52 +125,6 @@ export const getSortColumnAsync = createAsyncThunk<
}
});
export const updateSortColumnAsync = createAsyncThunk<
{
/** empty */
},
{
// パラメータ
direction: DirectionType;
paramName: SortableColumnType;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/updateSortColumnAsync", async (args, thunkApi) => {
const { direction, paramName } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
try {
await usersApi.updateSortCcriteria(
{ direction, paramName },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistsAsync = createAsyncThunk<
GetTypistsResponse,
void,
@ -275,6 +229,12 @@ export const updateAssigneeAsync = createAsyncThunk<
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
@ -300,3 +260,88 @@ export const updateAssigneeAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const playbackAsync = createAsyncThunk<
{
/** empty */
},
{
direction: DirectionType;
paramName: SortableColumnType;
audioFileId: number;
isTypist: boolean;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/playbackAsync", async (args, thunkApi) => {
const { audioFileId, direction, paramName, isTypist } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
const usersApi = new UsersApi(config);
try {
// ユーザーがタイピストである場合に、ソート条件を保存する
if (isTypist) {
await usersApi.updateSortCriteria(
{ direction, paramName },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
}
await tasksApi.checkout(audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスが[Uploaded,Inprogress,Pending]以外、またはタスクが存在しない場合
if (error.code === "E010601") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"dictationPage.message.taskToPlaybackNoExists"
),
})
);
return thunkApi.rejectWithValue({ error });
}
// タスクをチェックアウトする権限がない
if (error.code === "E010602") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"dictationPage.message.noPlaybackAuthorization"
),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -23,17 +23,17 @@ import {
changeParamName,
changeDirection,
changeSelectedTask,
updateSortColumnAsync,
SortableColumnType,
changeAssignee,
listTypistsAsync,
listTypistGroupsAsync,
DirectionType,
selectIsLoading,
playbackAsync,
} from "features/dictation";
import { getTranslationID } from "translation";
import { Task } from "api/api";
import { isAdminUser, isAuthorUser } from "features/auth/utils";
import { isAdminUser, isAuthorUser, isTypistUser } from "features/auth/utils";
import { STATUS, LIMIT_TASK_NUM } from "../../features/dictation";
import uploaded from "../../assets/images/uploaded.svg";
import pending from "../../assets/images/pending.svg";
@ -51,6 +51,7 @@ const DictationPage: React.FC = (): JSX.Element => {
const isAdmin = isAdminUser();
const isAuthor = isAuthorUser();
const isTypist = isTypistUser();
// popup制御関係
const [
isChangeTranscriptionistPopupOpen,
@ -316,14 +317,65 @@ const DictationPage: React.FC = (): JSX.Element => {
[dispatch, sortDirection, sortableParamName]
);
const onPlayBack = useCallback(() => {
dispatch(
updateSortColumnAsync({
direction: sortDirection,
paramName: sortableParamName,
})
);
}, [dispatch, sortDirection, sortableParamName]);
const onPlayBack = useCallback(
async (audioFileId: number) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
playbackAsync({
audioFileId,
direction: sortDirection,
paramName: sortableParamName,
isTypist,
})
);
if (meta.requestStatus === "fulfilled") {
const filter = getFilter(
filterUploaded,
filterInProgress,
filterPending,
filterFinished,
filterBackup
);
dispatch(
listTasksAsync({
limit: LIMIT_TASK_NUM,
offset: 0,
filter,
direction: sortDirection,
paramName: sortableParamName,
})
);
dispatch(listTypistsAsync());
dispatch(listTypistGroupsAsync());
const url = `${
import.meta.env.VITE_DESK_TOP_APP_SCHEME
}:playback?audioId=${audioFileId}`;
const a = document.createElement("a");
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
},
[
dispatch,
filterBackup,
filterFinished,
filterInProgress,
filterPending,
filterUploaded,
isTypist,
sortDirection,
sortableParamName,
t,
]
);
const onClosePopup = useCallback(
(isChanged: boolean) => {
@ -949,7 +1001,7 @@ const DictationPage: React.FC = (): JSX.Element => {
<ul className={styles.menuInTable}>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a onClick={onPlayBack}>
<a onClick={() => onPlayBack(x.audioFileId)}>
{t(
getTranslationID(
"dictationPage.label.playback"

View File

@ -32,10 +32,11 @@ const LoginPage: React.FC = (): JSX.Element => {
if (meta.requestStatus === "fulfilled") {
const accessToken = loadAccessToken();
const refreshToken = loadRefreshToken();
/* TODO 1899
*/
const url = `note:login?accessToken=${accessToken}&refreshToken=${refreshToken}&language=${i18n.language}`; // カスタムURLスキーム
const url = `${
import.meta.env.VITE_DESK_TOP_APP_SCHEME
}:login?accessToken=${accessToken}&refreshToken=${refreshToken}&language=${
i18n.language
}`; // カスタムURLスキーム
const a = document.createElement("a");
a.href = url;
document.body.appendChild(a);

View File

@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_B2C_CLIENTID: string;
readonly VITE_B2C_AUTHORITY: string;
readonly VITE_B2C_KNOWNAUTHORITIES: string;
readonly VITE_DESK_TOP_APP_SCHEME: string;
}
interface ImportMeta {

View File

@ -174,6 +174,8 @@
},
"dictationPage": {
"message": {
"noPlaybackAuthorization": "(de)本タスクをPlayBackできる権限がありません。",
"taskToPlaybackNoExists": "(de)タスクがすでに文字起こし完了済みまたは存在しないため、PlayBackできません。",
"taskNotEditable": "(de)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {

View File

@ -174,6 +174,8 @@
},
"dictationPage": {
"message": {
"noPlaybackAuthorization": "本タスクをPlayBackできる権限がありません。",
"taskToPlaybackNoExists": "タスクがすでに文字起こし完了済みまたは存在しないため、PlayBackできません。",
"taskNotEditable": "すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {

View File

@ -174,6 +174,8 @@
},
"dictationPage": {
"message": {
"noPlaybackAuthorization": "(es)本タスクをPlayBackできる権限がありません。",
"taskToPlaybackNoExists": "(es)タスクがすでに文字起こし完了済みまたは存在しないため、PlayBackできません。",
"taskNotEditable": "(es)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {

View File

@ -174,6 +174,8 @@
},
"dictationPage": {
"message": {
"noPlaybackAuthorization": "(fr)本タスクをPlayBackできる権限がありません。",
"taskToPlaybackNoExists": "(fr)タスクがすでに文字起こし完了済みまたは存在しないため、PlayBackできません。",
"taskNotEditable": "(fr)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {

View File

@ -510,7 +510,7 @@
},
"/users/sort-criteria": {
"post": {
"operationId": "updateSortCcriteria",
"operationId": "updateSortCriteria",
"summary": "",
"description": "ログインしているユーザーのタスクソート条件を更新します",
"parameters": [],
@ -564,7 +564,7 @@
"security": [{ "bearer": [] }]
},
"get": {
"operationId": "getSortCcriteria",
"operationId": "getSortCriteria",
"summary": "",
"description": "ログインしているユーザーのタスクソート条件を取得します",
"parameters": [],

View File

@ -249,7 +249,7 @@ export class UsersController {
type: ErrorResponse,
})
@ApiOperation({
operationId: 'updateSortCcriteria',
operationId: 'updateSortCriteria',
description: 'ログインしているユーザーのタスクソート条件を更新します',
})
@ApiBearerAuth()
@ -297,7 +297,7 @@ export class UsersController {
type: ErrorResponse,
})
@ApiOperation({
operationId: 'getSortCcriteria',
operationId: 'getSortCriteria',
description: 'ログインしているユーザーのタスクソート条件を取得します',
})
@ApiBearerAuth()

View File

@ -641,79 +641,79 @@ const makeOrder = (
switch (sort_criteria) {
case 'JOB_NUMBER':
return {
priority: 'ASC',
priority: 'DESC',
job_number: direction,
id: 'ASC',
};
case 'STATUS':
return {
priority: 'ASC',
priority: 'DESC',
status: direction,
id: 'ASC',
};
case 'TRANSCRIPTION_FINISHED_DATE':
return {
priority: 'ASC',
priority: 'DESC',
finished_at: direction,
id: 'ASC',
};
case 'TRANSCRIPTION_STARTED_DATE':
return {
priority: 'ASC',
priority: 'DESC',
started_at: direction,
id: 'ASC',
};
case 'AUTHOR_ID':
return {
priority: 'ASC',
priority: 'DESC',
file: { author_id: direction },
id: 'ASC',
};
case 'ENCRYPTION':
return {
priority: 'ASC',
priority: 'DESC',
file: { is_encrypted: direction },
id: 'ASC',
};
case 'FILE_LENGTH':
return {
priority: 'ASC',
priority: 'DESC',
file: { duration: direction },
id: 'ASC',
};
case 'FILE_NAME':
return {
priority: 'ASC',
priority: 'DESC',
file: { file_name: direction },
id: 'ASC',
};
case 'FILE_SIZE':
return {
priority: 'ASC',
priority: 'DESC',
file: { file_size: direction },
id: 'ASC',
};
case 'RECORDING_FINISHED_DATE':
return {
priority: 'ASC',
priority: 'DESC',
file: { finished_at: direction },
id: 'ASC',
};
case 'RECORDING_STARTED_DATE':
return {
priority: 'ASC',
priority: 'DESC',
file: { started_at: direction },
id: 'ASC',
};
case 'UPLOAD_DATE':
return {
priority: 'ASC',
priority: 'DESC',
file: { uploaded_at: direction },
id: 'ASC',
};
case 'WORK_TYPE':
return {
priority: 'ASC',
priority: 'DESC',
file: { work_type_id: direction },
id: 'ASC',
};