Merged PR 440: 画面実装(テンプレートファイルアップロードPopup)

## 概要
[Task2656: 画面実装(テンプレートファイルアップロードPopup)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2656)

- テンプレートファイルアップロードのAPI呼び出し周りを実装
  - SASトークン付きURL取得
  - Blobストレージへファイルアップロード
  - アップロード完了
- server側
  - `helmet`の`connect-src`を修正
  - SASトークン付きURLが想定と違っていたため修正
  - DBに保存するURLが想定と違っていたため修正

## レビューポイント
- `connect-src`の`self`以外はローカル環境のみの設定でよさそう?
- 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/Task2656?csf=1&web=1&e=iU1huG

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

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
saito.k 2023-09-27 06:36:24 +00:00
parent bf4dc1d717
commit 8265ca38c8
18 changed files with 1068 additions and 278 deletions

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"dependencies": {
"@azure/msal-browser": "^2.33.0",
"@azure/msal-react": "^1.5.3",
"@azure/storage-blob": "^12.16.0",
"@reduxjs/toolkit": "^1.8.3",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
@ -51,6 +52,7 @@
},
"devDependencies": {
"@babel/core": "^7.18.6",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@mdx-js/react": "^2.1.2",
"@openapitools/openapi-generator-cli": "^2.5.2",
"@types/lodash": "^4.14.191",

View File

@ -1,2 +1,3 @@
// TODO 仮で5MBにしているが、OMDS様からの回答待ち
// アップロード可能なファイルサイズの上限(MB)
export const UPLOAD_FILE_SIZE_LIMIT: number = 5 * 1024 * 1024;

View File

@ -1,9 +1,15 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { Configuration, GetTemplatesResponse, TemplatesApi } from "api";
import {
Configuration,
FilesApi,
GetTemplatesResponse,
TemplatesApi,
} 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 { BlockBlobClient, ContainerClient } from "@azure/storage-blob";
export const listTemplateAsync = createAsyncThunk<
GetTemplatesResponse,
@ -40,3 +46,69 @@ export const listTemplateAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const uploadTemplateAsync = createAsyncThunk<
{
/* Empty Object */
},
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("workflow/uploadTemplateAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const { uploadFile } = state.template.apps;
const config = new Configuration(configuration);
const filesApi = new FilesApi(config);
try {
if (!uploadFile) {
throw new Error("uploadFile is not found");
}
// SAS付きのURLを取得する
const { data } = await filesApi.uploadTemplateLocation({
headers: { authorization: `Bearer ${accessToken}` },
});
const { url } = data;
// ファイルをアップロードする
const containerClient = new ContainerClient(url);
const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(
uploadFile.name
);
await blockBlobClient.uploadData(uploadFile);
await filesApi.uploadTemplateFinished(
{
name: uploadFile.name,
url,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -7,6 +7,9 @@ export const selectTemplates = (state: RootState) =>
export const selectIsLoading = (state: RootState) =>
state.template.apps.isLoading;
export const selectIsUploading = (state: RootState) =>
state.template.apps.isUploading;
export const selectUploadFile = (state: RootState) =>
state.template.apps.uploadFile;

View File

@ -7,6 +7,7 @@ export interface TemplateState {
export interface Apps {
isLoading: boolean;
isUploading: boolean;
uploadFile?: File;
}

View File

@ -1,10 +1,11 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { TemplateState } from "./state";
import { listTemplateAsync } from "./operations";
import { listTemplateAsync, uploadTemplateAsync } from "./operations";
const initialState: TemplateState = {
apps: {
isLoading: false,
isUploading: false,
uploadFile: undefined,
},
domain: {},
@ -35,6 +36,15 @@ export const templateSlice = createSlice({
builder.addCase(listTemplateAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(uploadTemplateAsync.pending, (state) => {
state.apps.isUploading = true;
});
builder.addCase(uploadTemplateAsync.fulfilled, (state) => {
state.apps.isUploading = false;
});
builder.addCase(uploadTemplateAsync.rejected, (state) => {
state.apps.isUploading = false;
});
},
});

View File

@ -1,37 +1,53 @@
import React, { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "app/store";
import {
cleanupTemplate,
selectUploadFile,
changeUploadFile,
cleanupTemplate,
listTemplateAsync,
selectIsUploading,
selectUploadFile,
selectUploadFileError,
uploadTemplateAsync,
} from "features/workflow/template";
import React, { useCallback, useEffect, useState } from "react";
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";
import progress_activit from "../../assets/images/progress_activit.svg";
interface AddTemplateFilePopupProps {
onClose: () => void;
isOpen: boolean;
}
export const AddTemplateFilePopup: React.FC<AddTemplateFilePopupProps> = (
props: AddTemplateFilePopupProps
) => {
const { onClose, isOpen } = props;
const { onClose } = props;
const [t] = useTranslation();
const dispatch: AppDispatch = useDispatch();
// 閉じるボタンを押したときの処理
const closePopup = useCallback(() => {
onClose();
dispatch(cleanupTemplate());
}, [onClose, dispatch]);
// 保存ボタンを押したかどうか
const [isPushUploadButton, setIsPushUploadButton] = useState<boolean>(false);
// アップロード対象のファイル情報
const uploadFile = useSelector(selectUploadFile);
// ファイルアップロード中かどうか
const isUploading = useSelector(selectIsUploading);
// ファイルのエラー
const { hasErrorFileSize, hasErrorRequired } = useSelector(
selectUploadFileError
);
// ブラウザのウィンドウが閉じられようとしている場合に発火するイベントハンドラ
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// テンプレートファイルアップロード中に閉じられようとしている場合、ダイアログを表示させる
if (isUploading) {
e.preventDefault();
// ChromeではreturnValueが必要
e.returnValue = "";
}
};
// ファイルが選択されたときの処理
const handleFileChange = useCallback(
@ -43,12 +59,48 @@ export const AddTemplateFilePopup: React.FC<AddTemplateFilePopupProps> = (
if (file) {
dispatch(changeUploadFile({ file }));
}
// 同名のファイルを選択した場合、onChangeが発火しないため、valueをクリアする
event.target.value = "";
},
[dispatch]
);
// ファイルアップロード処理
const handleUploadFile = useCallback(async () => {
setIsPushUploadButton(true);
// エラーチェックを実施
if (hasErrorFileSize || hasErrorRequired) {
return;
}
// ファイルアップロード処理
const { meta } = await dispatch(uploadTemplateAsync());
if (meta.requestStatus === "fulfilled") {
onClose();
dispatch(listTemplateAsync());
}
}, [dispatch, hasErrorFileSize, hasErrorRequired, onClose]);
// コンポーネントがマウントされた時にイベントハンドラを登録する
useEffect(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
// コンポーネントがアンマウントされるときにイベントハンドラを解除する
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
});
useEffect(
() => () => {
// useEffectのreturnとしてcleanupAppsを実行することで、ポップアップのアンマウント時に初期化を行う
dispatch(cleanupTemplate());
setIsPushUploadButton(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={`${styles.modal} ${styles.isShow}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("templateFilePage.label.addTemplate"))}
@ -57,7 +109,7 @@ export const AddTemplateFilePopup: React.FC<AddTemplateFilePopupProps> = (
src={close}
className={styles.modalTitleIcon}
alt="close"
onClick={closePopup}
onClick={onClose}
/>
</p>
<form className={styles.form}>
@ -80,6 +132,16 @@ export const AddTemplateFilePopup: React.FC<AddTemplateFilePopupProps> = (
onChange={handleFileChange}
/>
</label>
{isPushUploadButton && hasErrorRequired && (
<span className={`${styles.formError} ${styles.alignCenter}`}>
{t(getTranslationID("templateFilePage.label.fileEmptyError"))}
</span>
)}
{isPushUploadButton && hasErrorFileSize && (
<span className={`${styles.formError} ${styles.alignCenter}`}>
{t(getTranslationID("templateFilePage.label.fileSizeError"))}
</span>
)}
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
@ -88,8 +150,18 @@ export const AddTemplateFilePopup: React.FC<AddTemplateFilePopupProps> = (
value={t(
getTranslationID("templateFilePage.label.addTemplate")
)}
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
onClick={handleUploadFile}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
!isUploading ? styles.isActive : ""
}`}
/>
{isUploading && (
<img
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
)}
</dd>
</dl>
</form>

View File

@ -31,12 +31,13 @@ export const TemplateFilePage: React.FC = () => {
return (
<>
{isShowAddPopup && (
<AddTemplateFilePopup
onClose={() => {
setIsShowAddPopup(false);
}}
isOpen={isShowAddPopup}
/>
)}
<div className={styles.wrap}>
<Header userName="XXXXXXXX" />
<UpdateTokenTimer />

View File

@ -421,7 +421,7 @@
"chooseFile": "(de)Choose File",
"notFileChosen": "(de)- Not file chosen -",
"fileSizeTerms": "(de)Flie Name",
"fileSizeError": "(de)選択されたファイルのサイズが大きすぎます。サイズがMB以下のファイルを選択してください。",
"fileSizeError": "(de)選択されたファイルのサイズが大きすぎます。サイズが5MB以下のファイルを選択してください。",
"fileEmptyError": "(de)ファイル選択は必須です。ファイルを選択してください。"
}
},

View File

@ -421,7 +421,7 @@
"chooseFile": "Choose File",
"notFileChosen": "- Not file chosen -",
"fileSizeTerms": "Flie Name",
"fileSizeError": "選択されたファイルのサイズが大きすぎます。サイズがMB以下のファイルを選択してください。",
"fileSizeError": "選択されたファイルのサイズが大きすぎます。サイズが5MB以下のファイルを選択してください。",
"fileEmptyError": "ファイル選択は必須です。ファイルを選択してください。"
}
},

View File

@ -421,7 +421,7 @@
"chooseFile": "(es)Choose File",
"notFileChosen": "(es)- Not file chosen -",
"fileSizeTerms": "(es)Flie Name",
"fileSizeError": "(es)選択されたファイルのサイズが大きすぎます。サイズがMB以下のファイルを選択してください。",
"fileSizeError": "(es)選択されたファイルのサイズが大きすぎます。サイズが5MB以下のファイルを選択してください。",
"fileEmptyError": "(es)ファイル選択は必須です。ファイルを選択してください。"
}
},

View File

@ -421,7 +421,7 @@
"chooseFile": "(fr)Choose File",
"notFileChosen": "(fr)- Not file chosen -",
"fileSizeTerms": "(fr)Flie Name",
"fileSizeError": "(fr)選択されたファイルのサイズが大きすぎます。サイズがMB以下のファイルを選択してください。",
"fileSizeError": "(fr)選択されたファイルのサイズが大きすぎます。サイズが5MB以下のファイルを選択してください。",
"fileEmptyError": "(fr)ファイル選択は必須です。ファイルを選択してください。"
}
},

View File

@ -16,4 +16,9 @@ export default defineConfig({
minify: false,
},
plugins: [env(), tsconfigPaths(), react(), sassDts()],
resolve: {
alias: {
os: "rollup-plugin-node-polyfills/polyfills/os",
},
},
});

View File

@ -8,6 +8,7 @@ AZURE_CLIENT_SECRET=xxxxxxxx
ADB2C_TENANT_ID=xxxxxxxx
ADB2C_CLIENT_ID=xxxxxxxx
ADB2C_CLIENT_SECRET=xxxxxxxx
ADB2C_ORIGIN=https://zzzzzzzzzz
KEY_VAULT_NAME=kv-odms-secret-dev
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA5IZZNgDew9eGmuFTezwdHYLSaJvUPPIKYoiOeVLD1paWNI51\n7Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3yCTR6wcWR3PfFJrl9vh5SOo79koZ\noJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbWFJXnDe0DVXYXpJLb4LAlF2XAyYX0\nSYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qSfiL9zWk9dvHoKzSnfSDzDFoFcEoV\nchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//mBNNaDHv83Yuw3mGShT73iJ0JQdk\nTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GOOQIDAQABAoIBADrwp7u097+dK/tw\nWD61n3DIGAqg/lmFt8X4IH8MKLSE/FKr16CS1bqwOEuIM3ZdUtDeXd9Xs7IsyEPE\n5ZwuXK7DSF0M4+Mj8Ip49Q0Aww9aUoLQU9HGfgN/r4599GTrt31clZXA/6Mlighq\ncOZgCcEfdItz8OMu5SQuOIW4CKkCuaWnPOP26UqZocaXNZfpZH0iFLATMMH/TT8x\nay9ToHTQYE17ijdQ/EOLSwoeDV1CU1CIE3P4YfLJjvpKptly5dTevriHEzBi70Jx\n/KEPUn9Jj2gZafrUxRVhmMbm1zkeYxL3gsqRuTzRjEeeILuZhSJyCkQZyUNARxsg\nQY4DZfECgYEA+YLKUtmYTx60FS6DJ4s31TAsXY8kwhq/lB9E3GBZKDd0DPayXEeK\n4UWRQDTT6MI6fedW69FOZJ5sFLp8HQpcssb4Weq9PCpDhNTx8MCbdH3Um5QR3vfW\naKq/1XM8MDUnx5XcNYd87Aw3azvJAvOPr69as8IPnj6sKaRR9uQjbYUCgYEA6nfV\n5j0qmn0EJXZJblk4mvvjLLoWSs17j9YlrZJlJxXMDFRYtgnelv73xMxOMvcGoxn5\nifs7dpaM2x5EmA6jVU5sYaB/beZGEPWqPYGyjIwXPvUGAAv8Gbnvpp+xlSco/Dum\nIq0w+43ry5/xWh6CjfrvKV0J2bDOiJwPEdu/8iUCgYEAnBBSvL+dpN9vhFAzeOh7\nY71eAqcmNsLEUcG9MJqTKbSFwhYMOewF0iHRWHeylEPokhfBJn8kqYrtz4lVWFTC\n5o/Nh3BsLNXCpbMMIapXkeWiti1HgE9ErPMgSkJpwz18RDpYIqM8X+jEQS6D7HSr\nyxfDg+w+GJza0rEVE3hfMIECgYBw+KZ2VfhmEWBjEHhXE+QjQMR3s320MwebCUqE\nNCpKx8TWF/naVC0MwfLtvqbbBY0MHyLN6d//xpA9r3rLbRojqzKrY2KiuDYAS+3n\nzssRzxoQOozWju+8EYu30/ADdqfXyIHG6X3VZs87AGiQzGyJLmP3oR1y5y7MQa09\nJI16hQKBgHK5uwJhGa281Oo5/FwQ3uYLymbNwSGrsOJXiEu2XwJEXwVi2ELOKh4/\n03pBk3Kva3fIwEK+vCzDNnxShIQqBE76/2I1K1whOfoUehhYvKHGaXl2j70Zz9Ks\nrkGW1cx7p+yDqATDrwHBHTHFh5bUTTn8dN40n0e0W/llurpbBkJM\n-----END RSA PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd\nHYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3\nyCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW\nFJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS\nfiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//\nmBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO\nOQIDAQAB\n-----END PUBLIC KEY-----\n"

View File

@ -620,7 +620,7 @@ export class FilesService {
await this.templateFilesRepository.upsertTemplateFile(
accountId,
fileName,
url,
fileUrl,
);
} catch (e) {
this.logger.error(`error=${e}`);

View File

@ -281,8 +281,8 @@ export class BlobstorageService {
},
sharedKeyCredential,
);
const url = new URL('Templates', containerClient.url);
// baseパスの末尾に/をつけないとパスの最後のセグメントが無視される
const url = new URL('Templates', `${containerClient.url}/`);
url.search = `${sasToken}`;
return url.toString();

View File

@ -5,11 +5,17 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet';
const helmetDirectives = helmet.contentSecurityPolicy.getDefaultDirectives();
helmetDirectives['connect-src'] = [
helmetDirectives['connect-src'] =
process.env.STAGE === 'local'
? [
"'self'",
'https://adb2codmsdev.b2clogin.com/adb2codmsdev.onmicrosoft.com/b2c_1_signin_dev/v2.0/.well-known/openid-configuration',
'https://adb2codmsdev.b2clogin.com/adb2codmsdev.onmicrosoft.com/b2c_1_signin_dev/oauth2/v2.0/token',
];
process.env.ADB2C_ORIGIN,
process.env.STORAGE_ACCOUNT_ENDPOINT_US,
process.env.STORAGE_ACCOUNT_ENDPOINT_AU,
process.env.STORAGE_ACCOUNT_ENDPOINT_EU,
]
: ["'self'"];
helmetDirectives['navigate-to'] = ["'self'"];
helmetDirectives['style-src'] = ["'self'", 'https:'];
helmetDirectives['report-uri'] = ["'self'"];