├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── bin │ └── backend.ts ├── cdk.json ├── jest.config.js ├── lambda │ ├── common.py │ ├── export │ │ └── post.py │ ├── extract │ │ ├── get_history.py │ │ └── post.py │ ├── report │ │ ├── get.py │ │ ├── get_by_id.py │ │ ├── post.py │ │ └── post_share.py │ ├── requirements.txt │ ├── table │ │ ├── get.py │ │ └── get_by_name.py │ └── user │ │ └── get.py ├── lib │ ├── backend-stack.ts │ └── constructs │ │ ├── auth.ts │ │ ├── backend-api.ts │ │ ├── database.ts │ │ └── network.ts ├── package-lock.json ├── package.json ├── test │ └── backend.test.ts └── tsconfig.json ├── docs ├── BACKEND.md ├── FRONTEND.md └── imgs │ ├── architecture.png │ └── demo.gif └── frontend ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── cdk ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── frontend.ts ├── cdk.json ├── jest.config.js ├── lib │ ├── frontend-stack.ts │ └── frontend-waf-stack.ts ├── package-lock.json ├── package.json ├── test │ └── cdk.test.ts └── tsconfig.json ├── index.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── src ├── @types │ ├── myPage.d.ts │ ├── react-i18next.d.ts │ └── report.d.ts ├── App.tsx ├── api │ ├── useExportApi.ts │ ├── useExtractionApi.ts │ ├── useReportApi.ts │ ├── useTableApi.ts │ └── useUserApi.ts ├── assets │ └── react.svg ├── components │ ├── AppDrawer.tsx │ ├── AppHeader.tsx │ ├── DatePicker.tsx │ ├── DialogSelectLanguage.tsx │ ├── Layout.tsx │ ├── Loading.tsx │ └── LoadingList.tsx ├── features │ ├── auth │ │ ├── components │ │ │ └── RequiresAuth.tsx │ │ └── pages │ │ │ └── SignInPage.tsx │ ├── myPage │ │ ├── components │ │ │ ├── CardProfile.tsx │ │ │ ├── DialogShareReport.tsx │ │ │ ├── ListExtractHistory.tsx │ │ │ ├── ListItemExtractHistory.tsx │ │ │ ├── ListItemMenu.tsx │ │ │ ├── ListMenu.tsx │ │ │ ├── ListMyReport.tsx │ │ │ ├── ListReport.tsx │ │ │ └── ListSharedReport.tsx │ │ └── pages │ │ │ └── MyPage.tsx │ └── report │ │ ├── components │ │ ├── ButtonExtract.tsx │ │ ├── CardArea.tsx │ │ ├── CardAreaDnD.tsx │ │ ├── CardExtractConditionDnD.tsx │ │ ├── CardSelectColumnDnD.tsx │ │ ├── ContainerExtractSetting.tsx │ │ ├── DialogRegisterReport.tsx │ │ ├── DisplayColumn.tsx │ │ ├── InputConditionDate.tsx │ │ ├── ItemColumn.tsx │ │ ├── ItemExtractCondition.tsx │ │ ├── ItemExtractConditionDnD.tsx │ │ ├── ItemSortableColumnDnD.tsx │ │ ├── SelectExtractCondition.tsx │ │ ├── SelectTable.tsx │ │ └── TableReport.tsx │ │ ├── hooks │ │ ├── useReportState.ts │ │ └── useRestoreReportState.ts │ │ └── pages │ │ ├── ReportConditionsPage.tsx │ │ └── ReportExtractPage.tsx ├── hooks │ ├── useAlertSnackbar.ts │ ├── useAuth.ts │ ├── useHttp.ts │ ├── useLoading.ts │ ├── useLocale.ts │ ├── useMuiTheme.ts │ └── useSortableDnD.ts ├── i18n │ ├── en │ │ └── index.ts │ ├── index.ts │ └── ja │ │ └── index.ts ├── index.css ├── main.tsx ├── providers │ ├── AlertSnackbarProvider.tsx │ ├── AppProvider.tsx │ ├── LoadingProvider.tsx │ └── MuiThemeProvider.tsx ├── recoil │ ├── AlertSnackbarState.ts │ ├── DrawerState.ts │ └── LoadingState.ts ├── utils │ └── DateUtils.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data Exporter 2 | 3 | Data exporter is a solution for extracting and downloading data from datawarehouses (Redshift) by specifying conditions with a simple user interface, without the need to write SQL or code. 4 | 5 | ## Demo 6 | 7 | ![demo](docs/imgs/demo.gif) 8 | 9 | ## Features 10 | 11 | - Easy data extraction 12 | - Data exporter provides a user-friendly interface that enables you to effortlessly specify conditions and retrieve data without the need to write SQL or code. 13 | - Query template (Report) 14 | - You can create reusable query templates (= report) that can be shared with others, facilitating collaboration and saving time. 15 | - Query history 16 | - Data Exporter automatically saves your query history. 17 | - Multilingual Support 18 | - Data Exproter supports multiple languages (English and Japanese). 19 | - Permission Control 20 | - You can control data permisson of users. (At this time, it is possilbe to set whether or not a user has data export privileges). 21 | 22 | ## Architecture 23 | 24 | ![architecture](docs/imgs/architecture.png) 25 | 26 | ## Getting started 27 | 28 | ### Prerequisites 29 | 30 | - Configuration of AWS profile 31 | - `cdk` command 32 | - Before installing `cdk` command, you need to install Node.js (Active LTS version preferred) on your machine. 33 | - You can install `cdk` with `npm install -g aws-cdk`. 34 | - Docker 35 | - Docker is used to create packages for Lambda functions. 36 | 37 | ### Deployment 38 | 39 | #### 1. CDK Setup 40 | 41 | The first step is to set up CDK. This step is required when using CDK for the first time for each AWS account and region. If you have already done so, you may skip this step. 42 | 43 | ```bash 44 | cdk bootstrap 45 | ``` 46 | 47 | AWS WAF for CloudFront is only available in `us-east-1` (see [reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-webacl.html).) If you have done the above setup in a region other than `us-east-1`, you also need to set up for `us-east-1` with the command below. 48 | 49 | ```bash 50 | cdk bootstrap --region us-east-1 51 | ``` 52 | 53 | #### 2. Backend Deployment 54 | 55 | Move to backend directory, and execute the following commands to install backend modules. 56 | 57 | ```bash 58 | cd backend 59 | npm ci 60 | ``` 61 | 62 | Once modules are installed, deploy backend resources defined by CDK with the following command. 63 | 64 | ```bash 65 | cdk deploy --all 66 | ``` 67 | 68 | If the deployment completes successfully, you will get outputs like below. These values will be used in the following steps. 69 | 70 | ```bash 71 | Outputs: 72 | BackendStack.AuthUserPoolClientIdxxx = xxxxx 73 | BackendStack.AuthUserPoolIdxxx = ap-northeast-1_xxxxx 74 | BackendStack.BackendApiEndpointxxx = https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ 75 | ``` 76 | 77 | #### 3. Frontend Deployment 78 | 79 | First, set the `context` in `frontend/cdk/cdk.json` to the values you got when deploying the backend. These values can also be found in the "Output" tab of the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/). 80 | 81 | ```json 82 | "context": { 83 | "userPoolId": "Cognito User Pool ID", 84 | "userPoolClientId": "Cognito User Pool Client ID", 85 | "allowedIpAddressRanges": [ 86 | "0.0.0.0/0" // Specify in CIDR format 87 | ], 88 | // ... 89 | } 90 | ``` 91 | 92 | Execute the following commands to install frontend modules. 93 | 94 | ```bash 95 | cd frontend/cdk/ 96 | npm ci 97 | ``` 98 | 99 | Once modules are installed, deploy frontend resources defined by CDK with the following command. React programs are automatically built before deployment begins. 100 | 101 | ```bash 102 | cdk deploy --all 103 | ``` 104 | 105 | When the deployment is successfully completed, the following outputs will be displayed. You have access to frontend website with this URL. 106 | 107 | ``` 108 | Outputs: 109 | FrontendStack.CloudFrontURL = https://xxxxxx.cloudfront.net 110 | ``` 111 | 112 | #### 4. Creation of Cognito user 113 | 114 | To login the website, you need to craete a Cognito user. In [Cognito Console](https://console.aws.amazon.com/cognito/v2/idp/user-pools/), create user with your email and password. 115 | 116 | After creation, you need to setup the custom attribute for a user. In the user page, click [User attributes] -> [Edit], and set [custom:Downloadable] to True or False. With this attribute, you can control who can export the table. 117 | 118 | You may also create a user with the attribute via AWS CLI. 119 | 120 | ```bash 121 | aws cognito-idp admin-create-user --user-pool-id "Cognito User Pool ID" --username "Email Address" --user-attributes "Name=custom:Downloadable,Value=True" --temporary-password "Temporary Password" 122 | ``` 123 | 124 | ## Documentation 125 | 126 | You can access the following links for more information on frontend development and backend API and DB design. 127 | 128 | - [Frontend](docs/FRONTEND.md) 129 | - [Backend](docs/BACKEND.md) 130 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /backend/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `cdk deploy` deploy this stack to your default AWS account/region 13 | * `cdk diff` compare deployed stack with current state 14 | * `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /backend/bin/backend.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { BackendStack } from '../lib/backend-stack'; 5 | 6 | const app = new cdk.App(); 7 | new BackendStack(app, 'BackendStack', { 8 | /* If you don't specify 'env', this stack will be environment-agnostic. 9 | * Account/Region-dependent features and context lookups will not work, 10 | * but a single synthesized template can be deployed anywhere. */ 11 | /* Uncomment the next line to specialize this stack for the AWS Account 12 | * and Region that are implied by the current CLI configuration. */ 13 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 14 | /* Uncomment the next line if you know exactly what Account and Region you 15 | * want to deploy the stack to. */ 16 | // env: { account: '123456789012', region: 'us-east-1' }, 17 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ 18 | }); 19 | -------------------------------------------------------------------------------- /backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/backend.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 38 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 39 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 40 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 41 | "@aws-cdk/aws-route53-patters:useCertificate": true, 42 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 43 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 44 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 45 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 46 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 47 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 48 | "@aws-cdk/aws-redshift:columnId": true 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /backend/lambda/common.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from datetime import date, datetime 3 | from decimal import Decimal 4 | import json 5 | import os 6 | import psycopg2 7 | from urllib.parse import urlparse 8 | 9 | 10 | REDSHIFT_HOST = os.environ['REDSHIFT_HOST'] 11 | SECRET_ID = os.environ['SECRET_ID'] 12 | 13 | 14 | class NotFoundError(Exception): 15 | pass 16 | 17 | 18 | def default_proc(obj): 19 | if isinstance(obj, Decimal): 20 | return float(obj) 21 | if isinstance(obj, datetime) or isinstance(obj, date): 22 | return obj.isoformat() 23 | raise TypeError 24 | 25 | 26 | def get_secret(): 27 | client = boto3.client('secretsmanager') 28 | secret = json.loads(client.get_secret_value( 29 | SecretId=SECRET_ID)['SecretString']) 30 | return secret 31 | 32 | 33 | def create_connection(): 34 | secret = get_secret() 35 | conn = psycopg2.connect( 36 | host=REDSHIFT_HOST, 37 | port=secret['port'], 38 | database=secret['database'], 39 | user=secret['user'], 40 | password=secret['password'] 41 | ) 42 | return conn 43 | 44 | 45 | def make_response(status_code, body=None): 46 | return { 47 | 'isBase64Encoded': False, 48 | 'statusCode': status_code, 49 | 'body': json.dumps(body, ensure_ascii=False, default=default_proc) if body else '', 50 | 'headers': { 51 | 'Content-Type': 'application/json', 52 | 'Access-Control-Allow-Origin': '*' 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /backend/lambda/export/post.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from http import HTTPStatus 3 | import json 4 | import os 5 | import uuid 6 | 7 | from common import make_response 8 | 9 | 10 | def create_csv(columns, table): 11 | """ 12 | Create csv from columns and table information received from client 13 | """ 14 | 15 | csv_file_path = f'/tmp/{str(uuid.uuid4())}' 16 | with open(csv_file_path, mode='w', newline='') as f: 17 | writer = csv.writer(f) 18 | writer.writerow(columns) 19 | writer.writerows(table) 20 | 21 | return csv_file_path 22 | 23 | 24 | def handler(event, context): 25 | body = json.loads(event['body']) 26 | claims = event['requestContext']['authorizer']['claims'] 27 | if (not claims.get('custom:Downloadable')) or (claims['custom:Downloadable'] == 'false'): 28 | return make_response( 29 | status_code=HTTPStatus.FORBIDDEN 30 | ) 31 | 32 | columns, table = body['columns'], body['table'] 33 | if columns is None or table is None: 34 | return make_response( 35 | status_code=HTTPStatus.BAD_REQUEST 36 | ) 37 | 38 | csv_file_path = create_csv(columns, table) 39 | 40 | with open(csv_file_path) as f: 41 | csv_content = f.read() 42 | response = { 43 | "statusCode": HTTPStatus.OK, 44 | "headers": { 45 | "Content-Type": "text/csv", 46 | 'Access-Control-Allow-Origin': '*' 47 | }, 48 | "body": csv_content 49 | } 50 | 51 | try: 52 | os.remove(csv_file_path) 53 | except Exception as e: 54 | print(e) 55 | 56 | return response 57 | -------------------------------------------------------------------------------- /backend/lambda/extract/get_history.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from http import HTTPStatus 3 | import os 4 | 5 | from common import make_response 6 | 7 | HISTORY_TABLE_NAME = os.environ['HISTORY_TABLE_NAME'] 8 | dynamodb = boto3.resource('dynamodb') 9 | history_table = dynamodb.Table(HISTORY_TABLE_NAME) 10 | 11 | 12 | def get_extraction_history(user_id): 13 | """ 14 | Retrieve the extraction history in chronological order by specifying the user ID. 15 | """ 16 | responses = history_table.query( 17 | KeyConditionExpression='userId = :userId', 18 | ExpressionAttributeValues={':userId': user_id}, 19 | ScanIndexForward=False # 時系列で降順に取得する 20 | ) 21 | if not responses.get('Items'): 22 | return [] 23 | 24 | history_records = [] 25 | for response in responses['Items']: 26 | history_record = {} 27 | history_record['tableName'] = response['tableName'] 28 | history_record['conditions'] = response['conditions'] 29 | history_record['columns'] = response['columns'] 30 | history_record['extractionTime'] = response['timestamp'] 31 | history_records.append(history_record) 32 | return history_records 33 | 34 | 35 | def handler(event, context): 36 | user_id = event['requestContext']['authorizer']['claims']['sub'] 37 | 38 | history_records = get_extraction_history(user_id) 39 | return make_response( 40 | status_code=HTTPStatus.OK, 41 | body={'historyRecords': history_records} 42 | ) 43 | -------------------------------------------------------------------------------- /backend/lambda/extract/post.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from datetime import datetime 3 | from http import HTTPStatus 4 | import json 5 | import os 6 | from psycopg2 import sql 7 | import uuid 8 | 9 | from common import create_connection, make_response 10 | 11 | HISTORY_TABLE_NAME = os.environ['HISTORY_TABLE_NAME'] 12 | dynamodb = boto3.resource('dynamodb') 13 | history_table = dynamodb.Table(HISTORY_TABLE_NAME) 14 | MAX_ROWS_SIZE = 5000 15 | 16 | 17 | def create_where_statement(conditions): 18 | """ 19 | Convert extraction conditions to SQL WHERE statements 20 | """ 21 | if not conditions: 22 | return None 23 | clauses = [] 24 | # DB table and column names are stored in "identifiers", 25 | # and conditional values are stored in "params". 26 | identifiers, params = [], [] 27 | for condition in conditions: 28 | # String conditions 29 | if condition['type'] == 'string': 30 | if condition['operator'] == 'eq': 31 | op = ' = ' 32 | elif condition['operator'] == 'neq': 33 | op = ' <> ' 34 | elif condition['operator'] == 'contains': 35 | op = ' LIKE ' 36 | condition['value'] = '%' + \ 37 | condition['value'] + '%' 38 | clause = f"{{}} {op} %s" 39 | identifiers.append(sql.Identifier(condition['columnName'])) 40 | params.append(condition['value']) 41 | 42 | # Number conditions 43 | elif condition['type'] == 'number': 44 | if condition['operator'] == 'eq': 45 | op = ' = ' 46 | elif condition['operator'] == 'neq': 47 | op = ' <> ' 48 | elif condition['operator'] == 'gt': 49 | op = ' > ' 50 | elif condition['operator'] == 'lt': 51 | op = ' < ' 52 | elif condition['operator'] == 'gte': 53 | op = ' >= ' 54 | elif condition['operator'] == 'lte': 55 | op = ' <= ' 56 | clause = f"{{}} {op} %s" 57 | identifiers.append(sql.Identifier(condition['columnName'])) 58 | params.append(condition['value']) 59 | 60 | # Absolute date conditions 61 | elif condition['type'] == 'absoluteDate': 62 | clause = "{}::date BETWEEN %s AND %s" 63 | identifiers.append(sql.Identifier(condition['columnName'])) 64 | params.extend([condition['startDate'], condition['endDate']]) 65 | 66 | # Reletive date (N value specified) conditions 67 | elif condition['type'] == 'relativeDateN': 68 | if condition['period'] == 'day': 69 | clause = "{} = CURRENT_DATE - INTERVAL %s" 70 | identifiers.append(sql.Identifier(condition['columnName'])) 71 | params.append(str(condition['n']) + condition['period']) 72 | 73 | else: 74 | clause = "{} >= date_trunc(%s, CURRENT_DATE) - INTERVAL %s AND {} < date_trunc(%s, CURRENT_DATE) - INTERVAL %s" 75 | identifiers.extend([sql.Identifier( 76 | condition['columnName']), sql.Identifier(condition['columnName'])]) 77 | params.extend([condition['period'], str(condition['n']) + condition['period'], 78 | condition['period'], str(int(condition['n']) - 1) + condition['period']]) 79 | 80 | # Relative date (today, yesterday, last week, last month, last year) conditions 81 | elif condition['type'] == 'relativeDate': 82 | if condition['period'] == 'today': 83 | n = 0 84 | period = 'day' 85 | elif condition['period'] == 'yesterday': 86 | n = 1 87 | period = 'day' 88 | elif condition['period'] == 'lastWeek': 89 | n = 1 90 | period = 'week' 91 | elif condition['period'] == 'lastMonth': 92 | n = 1 93 | period = 'month' 94 | elif condition['period'] == 'lastYear': 95 | n = 1 96 | period = 'year' 97 | clause = "{} >= date_trunc(%s, CURRENT_DATE) - INTERVAL %s AND {} < date_trunc(%s, CURRENT_DATE) - INTERVAL %s" 98 | identifiers.extend([sql.Identifier( 99 | condition['columnName']), sql.Identifier(condition['columnName'])]) 100 | params.extend([period, str(n) + period, 101 | period, str(n - 1) + period]) 102 | 103 | # Time conditions 104 | elif condition['type'] == 'time': 105 | clause = "{}::time BETWEEN %s AND %s" 106 | identifiers.append(sql.Identifier(condition['columnName'])) 107 | params.extend([condition['startTime'], condition['endTime']]) 108 | 109 | clauses.append(clause) 110 | 111 | return 'WHERE ' + ' AND '.join(clauses), identifiers, params 112 | 113 | 114 | def create_select_statement(columns, table_name): 115 | """ 116 | Convert columns to extract to SQL SELECT statements 117 | """ 118 | if not columns: 119 | return None 120 | identifiers = list(map(sql.Identifier, columns)) + \ 121 | [sql.Identifier(table_name)] 122 | return 'SELECT ' + ', '.join(['{}'] * len(columns)) + ' FROM {} ', identifiers 123 | 124 | 125 | def save_extraction_history(columns, conditions, user_id, table_name): 126 | """ 127 | Store extraction history (extracted columns and conditions) in DynamoDB 128 | """ 129 | extraction_id = str(uuid.uuid4()) 130 | 131 | history_table.put_item( 132 | Item={ 133 | 'userId': user_id, 134 | 'timestamp': datetime.now().isoformat(), 135 | 'extractionId': extraction_id, 136 | 'tableName': table_name, 137 | 'columns': columns, 138 | 'conditions': conditions 139 | } 140 | ) 141 | 142 | 143 | def handler(event, context): 144 | conn = create_connection() 145 | cur = conn.cursor() 146 | body = json.loads(event['body']) 147 | conditions, columns, table_name = body['conditions'], body['columns'], body['tableName'] 148 | user_id = event['requestContext']['authorizer']['claims']['sub'] 149 | 150 | select_statement, select_identifiers = create_select_statement( 151 | columns, table_name) 152 | where_statement, where_identifiers, params = create_where_statement( 153 | conditions) 154 | if select_statement is None or where_statement is None: 155 | return make_response( 156 | status_code=HTTPStatus.BAD_REQUEST, 157 | body={'message': 'Bad Request'} 158 | ) 159 | 160 | try: 161 | # Execute extraction 162 | statement = select_statement + \ 163 | where_statement + f'LIMIT {MAX_ROWS_SIZE}' 164 | identifiers = select_identifiers + where_identifiers 165 | cur.execute(sql.SQL(statement).format(*identifiers), params) 166 | results = cur.fetchall() 167 | # Return error if the number of columns of data to be retrieved exceeds a certain size 168 | if len(results) > MAX_ROWS_SIZE: 169 | return make_response( 170 | status_code=HTTPStatus.BAD_REQUEST, 171 | body={'message': 'Expected response size is too large.'} 172 | ) 173 | items = [] 174 | for result in results: 175 | items.append(result) 176 | 177 | # Save extraction history 178 | save_extraction_history(columns, conditions, user_id, table_name) 179 | 180 | except Exception as e: 181 | print(e) 182 | return make_response( 183 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 184 | body={'message': 'Internal Server Error'} 185 | ) 186 | finally: 187 | cur.close() 188 | conn.close() 189 | 190 | return make_response( 191 | status_code=HTTPStatus.OK, 192 | body={'items': items} 193 | ) 194 | -------------------------------------------------------------------------------- /backend/lambda/report/get.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from http import HTTPStatus 3 | import os 4 | 5 | from common import make_response 6 | from report.get_by_id import get_report_by_id 7 | 8 | REPORT_TABLE_NAME = os.environ['REPORT_TABLE_NAME'] 9 | REPORT_ID_INDEX_NAME = os.environ['REPORT_ID_INDEX_NAME'] 10 | dynamodb = boto3.resource('dynamodb') 11 | report_table = dynamodb.Table(REPORT_TABLE_NAME) 12 | 13 | 14 | def get_my_report(user_id): 15 | """ 16 | Retrieve my reports 17 | """ 18 | response = report_table.query( 19 | KeyConditionExpression='userId = :userId and begins_with(sortKey, :sortKey)', 20 | ExpressionAttributeValues={ 21 | ':userId': user_id, 22 | ':sortKey': 'timestamp' 23 | }, 24 | ScanIndexForward=False # 時系列で降順に取得する 25 | ) 26 | reports = [] 27 | for item in response['Items']: 28 | report = {} 29 | report['reportId'] = item['reportId'] 30 | report['reportName'] = item['reportName'] 31 | report['tableName'] = item['tableName'] 32 | report['conditions'] = item['conditions'] 33 | report['columns'] = item['columns'] 34 | report['reportTime'] = item['sortKey'].split('#')[-1] 35 | reports.append(report) 36 | return reports 37 | 38 | 39 | def get_shared_report(user_id): 40 | """ 41 | Retrieving reports shared by other users 42 | """ 43 | response = report_table.query( 44 | KeyConditionExpression='userId = :userId and begins_with(sortKey, :sortKey)', 45 | ExpressionAttributeValues={ 46 | ':userId': user_id, 47 | ':sortKey': 'shared' 48 | }, 49 | ScanIndexForward=False # 時系列で降順に取得する 50 | ) 51 | reports = [] 52 | for item in response['Items']: 53 | report_id = item['sortKey'].split('#')[-1] 54 | report = get_report_by_id(report_id) 55 | reports.append(report) 56 | return reports 57 | 58 | 59 | def handler(event, context): 60 | user_id = event['requestContext']['authorizer']['claims']['sub'] 61 | report_type = event['queryStringParameters']['type'] 62 | 63 | try: 64 | if report_type == 'my': 65 | report = get_my_report(user_id) 66 | elif report_type == 'shared': 67 | report = get_shared_report(user_id) 68 | return make_response( 69 | status_code=HTTPStatus.OK, 70 | body={'report': report} 71 | ) 72 | except Exception as e: 73 | print(e) 74 | return make_response( 75 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR 76 | ) 77 | -------------------------------------------------------------------------------- /backend/lambda/report/get_by_id.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from http import HTTPStatus 3 | import os 4 | 5 | from common import make_response, NotFoundError 6 | 7 | REPORT_TABLE_NAME = os.environ['REPORT_TABLE_NAME'] 8 | REPORT_ID_INDEX_NAME = os.environ['REPORT_ID_INDEX_NAME'] 9 | dynamodb = boto3.resource('dynamodb') 10 | report_table = dynamodb.Table(REPORT_TABLE_NAME) 11 | 12 | 13 | def get_report_by_id(report_id): 14 | """ 15 | Retrieve a report by specifying the report ID 16 | """ 17 | response = report_table.query( 18 | IndexName=REPORT_ID_INDEX_NAME, 19 | KeyConditionExpression='reportId = :reportId', 20 | ExpressionAttributeValues={ 21 | ':reportId': report_id 22 | } 23 | ) 24 | if response.get('Count', 0) >= 1: 25 | report = {} 26 | report['reportId'] = report_id 27 | report['reportName'] = response['Items'][0]['reportName'] 28 | report['tableName'] = response['Items'][0]['tableName'] 29 | report['conditions'] = response['Items'][0]['conditions'] 30 | report['columns'] = response['Items'][0]['columns'] 31 | report['reportTime'] = response['Items'][0]['sortKey'].split('#')[-1] 32 | return report 33 | else: 34 | raise NotFoundError('Report not found') 35 | 36 | 37 | def handler(event, context): 38 | report_id = event['pathParameters']['reportId'] 39 | 40 | try: 41 | report = get_report_by_id(report_id) 42 | return make_response( 43 | status_code=HTTPStatus.OK, 44 | body={'report': report} 45 | ) 46 | except NotFoundError as e: 47 | return make_response( 48 | status_code=HTTPStatus.NOT_FOUND, 49 | body={ 50 | 'message': e.args[0] 51 | } 52 | ) 53 | except Exception as e: 54 | print(e) 55 | return make_response( 56 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR 57 | ) 58 | -------------------------------------------------------------------------------- /backend/lambda/report/post.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from datetime import datetime 3 | from http import HTTPStatus 4 | import json 5 | import os 6 | import uuid 7 | 8 | from common import make_response 9 | 10 | REPORT_TABLE_NAME = os.environ['REPORT_TABLE_NAME'] 11 | dynamodb = boto3.resource('dynamodb') 12 | report_table = dynamodb.Table(REPORT_TABLE_NAME) 13 | 14 | 15 | def save_report(columns, conditions, user_id, table_name, report_name): 16 | """ 17 | Save a report to DynamoDB 18 | """ 19 | report_id = str(uuid.uuid4()) 20 | 21 | try: 22 | report_table.put_item( 23 | Item={ 24 | 'userId': user_id, 25 | 'sortKey': f'timestamp#{datetime.now().isoformat()}', 26 | 'reportId': report_id, 27 | 'tableName': table_name, 28 | 'reportName': report_name, 29 | 'columns': columns, 30 | 'conditions': conditions 31 | } 32 | ) 33 | return report_id 34 | except Exception as e: 35 | print(e) 36 | 37 | 38 | def handler(event, context): 39 | user_id = event['requestContext']['authorizer']['claims']['sub'] 40 | body = json.loads(event['body']) 41 | conditions, columns, table_name, report_name = body['conditions'], body[ 42 | 'columns'], body['tableName'], body['reportName'] 43 | 44 | try: 45 | report_id = save_report(columns, conditions, 46 | user_id, table_name, report_name) 47 | return make_response( 48 | status_code=HTTPStatus.OK, 49 | body={ 50 | 'reportId': report_id 51 | } 52 | ) 53 | except: 54 | return make_response( 55 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR 56 | ) 57 | -------------------------------------------------------------------------------- /backend/lambda/report/post_share.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from http import HTTPStatus 3 | import json 4 | import os 5 | import uuid 6 | 7 | from common import make_response, NotFoundError 8 | 9 | REPORT_TABLE_NAME = os.environ['REPORT_TABLE_NAME'] 10 | REPORT_ID_INDEX_NAME = os.environ['REPORT_ID_INDEX_NAME'] 11 | USER_POOL_ID = os.environ['USER_POOL_ID'] 12 | dynamodb = boto3.resource('dynamodb') 13 | cognito = boto3.client('cognito-idp') 14 | report_table = dynamodb.Table(REPORT_TABLE_NAME) 15 | 16 | 17 | def share_report(user_id, shared_user_email, report_id): 18 | """ 19 | Share reports with other users 20 | """ 21 | # Get a user ID from email address 22 | response = cognito.list_users( 23 | UserPoolId=USER_POOL_ID, 24 | Filter=f'email="{shared_user_email}"' 25 | ) 26 | 27 | if not response.get('Users') or len(response['Users']) == 0: 28 | raise NotFoundError('User to share not found') 29 | 30 | for attribute in response['Users'][0]['Attributes']: 31 | if attribute['Name'] == 'sub': 32 | shared_user_id = attribute['Value'] 33 | 34 | # Checks if the specified report ID exists, and if so, shares it 35 | response = report_table.query( 36 | IndexName=REPORT_ID_INDEX_NAME, 37 | KeyConditionExpression='reportId = :reportId', 38 | ExpressionAttributeValues={ 39 | ':reportId': report_id 40 | }, 41 | Select='COUNT' 42 | ) 43 | 44 | if response.get('Count', 0) == 0: 45 | raise NotFoundError('Report not found') 46 | 47 | report_table.put_item( 48 | Item={ 49 | 'userId': shared_user_id, 50 | 'sortKey': f'shared#{report_id}', 51 | 'sharingUserId': user_id 52 | } 53 | ) 54 | 55 | 56 | def handler(event, context): 57 | user_id = event['requestContext']['authorizer']['claims']['sub'] 58 | report_id = event['pathParameters']['reportId'] 59 | body = json.loads(event['body']) 60 | shared_user_email = body['sharedUserEmail'] 61 | 62 | try: 63 | share_report(user_id, shared_user_email, report_id) 64 | return make_response( 65 | status_code=HTTPStatus.OK 66 | ) 67 | except NotFoundError as e: 68 | return make_response( 69 | status_code=HTTPStatus.NOT_FOUND, 70 | body={'message': e.args[0]} 71 | ) 72 | except Exception as e: 73 | print(e) 74 | return make_response( 75 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR 76 | ) 77 | -------------------------------------------------------------------------------- /backend/lambda/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary -------------------------------------------------------------------------------- /backend/lambda/table/get.py: -------------------------------------------------------------------------------- 1 | from common import create_connection, make_response 2 | from http import HTTPStatus 3 | 4 | 5 | def handler(event, context): 6 | conn = create_connection() 7 | cur = conn.cursor() 8 | 9 | # Get a list of Redshift table names 10 | try: 11 | cur.execute( 12 | "SELECT tablename FROM pg_tables WHERE schemaname = 'public';") 13 | tables = [table[0] for table in cur.fetchall()] 14 | except Exception as e: 15 | return make_response( 16 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 17 | body={'message': e} 18 | ) 19 | finally: 20 | cur.close() 21 | conn.close() 22 | 23 | return make_response( 24 | status_code=HTTPStatus.OK, 25 | body={'tables': tables} 26 | ) 27 | -------------------------------------------------------------------------------- /backend/lambda/table/get_by_name.py: -------------------------------------------------------------------------------- 1 | from common import create_connection, make_response 2 | from http import HTTPStatus 3 | 4 | # Convert Redshift (Postgres) types for a frontend 5 | column_type_alias = { 6 | 'bigint': 'number', 7 | 'bigserial': 'number', 8 | 'bit': 'string', 9 | 'bit varying': 'string', 10 | 'boolean': 'string', 11 | 'box': 'string', 12 | 'bytea': 'string', 13 | 'character': 'string', 14 | 'character varying': 'string', 15 | 'cidr': 'string', 16 | 'circle': 'string', 17 | 'date': 'date', 18 | 'double precision': 'number', 19 | 'inet': 'string', 20 | 'integer': 'number', 21 | 'interval': 'string', 22 | 'json': 'string', 23 | 'jsonb': 'string', 24 | 'line': 'string', 25 | 'lseg': 'string', 26 | 'macaddr': 'string', 27 | 'macaddr8': 'string', 28 | 'money': 'number', 29 | 'numeric': 'number', 30 | 'path': 'string', 31 | 'pg_lsn': 'string', 32 | 'pg_snapshot': 'string', 33 | 'point': 'string', 34 | 'polygon': 'string', 35 | 'real': 'number', 36 | 'smallint': 'number', 37 | 'smallserial': 'number', 38 | 'serial': 'number', 39 | 'text': 'string', 40 | 'time': 'date', 41 | 'time with time zone': 'date', 42 | 'time without time zone': 'date', 43 | 'timestamp': 'date', 44 | 'timestamp with time zone': 'date', 45 | 'timestamp without time zone': 'date', 46 | 'tsquery': 'string', 47 | 'tsvector': 'string', 48 | 'txid_snapshot': 'string', 49 | 'uuid': 'string', 50 | 'xml': 'string', 51 | } 52 | 53 | 54 | def handler(event, context): 55 | table_name = event['pathParameters']['tableName'] 56 | conn = create_connection() 57 | cur = conn.cursor() 58 | 59 | # Get column names and types by specifying the table name 60 | try: 61 | cur.execute( 62 | "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = %s ORDER BY ordinal_position", [table_name]) 63 | results = cur.fetchall() 64 | columns = [] 65 | for result in results: 66 | columns.append( 67 | {'name': result[0], 'type': column_type_alias[result[1]]}) 68 | except Exception as e: 69 | return make_response( 70 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 71 | body={'message': e} 72 | ) 73 | finally: 74 | cur.close() 75 | conn.close() 76 | 77 | return make_response( 78 | status_code=HTTPStatus.OK, 79 | body={'tableName': table_name, 'columns': columns} 80 | ) 81 | -------------------------------------------------------------------------------- /backend/lambda/user/get.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from common import make_response 3 | 4 | 5 | def handler(event, context): 6 | claims = event['requestContext']['authorizer']['claims'] 7 | print(claims) 8 | if claims.get('custom:Downloadable'): 9 | if claims['custom:Downloadable'] == 'true': 10 | return make_response( 11 | status_code=HTTPStatus.OK, 12 | body={ 13 | 'downloadable': True, 14 | 'email': claims['email'] 15 | } 16 | ) 17 | else: 18 | return make_response( 19 | status_code=HTTPStatus.OK, 20 | body={ 21 | 'downloadable': False, 22 | 'email': claims['email'] 23 | } 24 | ) 25 | else: 26 | return make_response( 27 | status_code=HTTPStatus.NOT_FOUND 28 | ) 29 | -------------------------------------------------------------------------------- /backend/lib/backend-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { Auth } from './constructs/auth'; 4 | import { BackendApi } from './constructs/backend-api'; 5 | import { Database } from './constructs/database'; 6 | import { Network } from './constructs/network'; 7 | 8 | export class BackendStack extends cdk.Stack { 9 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 10 | super(scope, id, props); 11 | 12 | const auth = new Auth(this, 'Auth'); 13 | const network = new Network(this, 'Network'); 14 | const database = new Database(this, 'Database', { 15 | vpc: network.vpc, 16 | redshiftSecurityGroup: network.redshiftSecurityGroup, 17 | }); 18 | const backendApi = new BackendApi(this, 'BackendApi', { 19 | userPool: auth.userPool, 20 | redshiftCluster: database.cluster, 21 | secret: database.secret, 22 | vpc: network.vpc, 23 | lambdaSecurityGroup: network.lambdaSecurityGroup, 24 | historyTable: database.historyTable, 25 | reportTable: database.reportTable, 26 | reportTableReportIdIndex: database.reportTableReportIdIndex, 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/lib/constructs/auth.ts: -------------------------------------------------------------------------------- 1 | import { CfnOutput, Duration } from 'aws-cdk-lib'; 2 | import { 3 | BooleanAttribute, 4 | UserPool, 5 | UserPoolClient, 6 | } from 'aws-cdk-lib/aws-cognito'; 7 | import { Construct } from 'constructs'; 8 | 9 | export class Auth extends Construct { 10 | readonly userPool: UserPool; 11 | readonly client: UserPoolClient; 12 | constructor(scope: Construct, id: string) { 13 | super(scope, id); 14 | 15 | const userPool = new UserPool(this, 'UserPool', { 16 | signInAliases: { 17 | username: false, 18 | email: true, 19 | }, 20 | passwordPolicy: { 21 | requireUppercase: true, 22 | requireSymbols: true, 23 | requireDigits: true, 24 | minLength: 8, 25 | }, 26 | customAttributes: { 27 | Downloadable: new BooleanAttribute({ mutable: true }), 28 | }, 29 | }); 30 | 31 | const client = userPool.addClient('client', { 32 | idTokenValidity: Duration.days(1), 33 | }); 34 | 35 | this.client = client; 36 | this.userPool = userPool; 37 | 38 | new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId }); 39 | new CfnOutput(this, 'UserPoolClientId', { value: client.userPoolClientId }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/lib/constructs/database.ts: -------------------------------------------------------------------------------- 1 | import { Cluster, DatabaseSecret } from '@aws-cdk/aws-redshift-alpha'; 2 | import { RemovalPolicy } from 'aws-cdk-lib'; 3 | import { 4 | AttributeType, 5 | BillingMode, 6 | GlobalSecondaryIndexProps, 7 | Table, 8 | TableEncryption, 9 | } from 'aws-cdk-lib/aws-dynamodb'; 10 | import { SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; 11 | import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; 12 | import { Construct } from 'constructs'; 13 | 14 | export interface DatabaseProps { 15 | vpc: Vpc; 16 | redshiftSecurityGroup: SecurityGroup; 17 | } 18 | 19 | export class Database extends Construct { 20 | readonly cluster: Cluster; 21 | readonly secret: Secret; 22 | readonly historyTable: Table; 23 | readonly reportTable: Table; 24 | readonly reportTableReportIdIndex: GlobalSecondaryIndexProps; 25 | constructor(scope: Construct, id: string, props: DatabaseProps) { 26 | super(scope, id); 27 | const { vpc, redshiftSecurityGroup } = props; 28 | 29 | // Redshift 30 | const secret = new Secret(this, 'RedshiftPassword', { 31 | generateSecretString: { 32 | secretStringTemplate: JSON.stringify({ 33 | user: 'admin', 34 | database: 'dev', 35 | port: '5439', 36 | }), 37 | generateStringKey: 'password', 38 | excludeCharacters: '"@/\\ \'', 39 | passwordLength: 16, 40 | }, 41 | removalPolicy: RemovalPolicy.DESTROY, 42 | }); 43 | 44 | const cluster = new Cluster(this, 'RedshiftCluster', { 45 | masterUser: { 46 | masterUsername: 'admin', 47 | masterPassword: secret.secretValueFromJson('password'), 48 | }, 49 | vpc: vpc, 50 | removalPolicy: RemovalPolicy.DESTROY, 51 | securityGroups: [redshiftSecurityGroup], 52 | }); 53 | 54 | this.cluster = cluster; 55 | this.secret = secret; 56 | 57 | // DynamoDB 58 | const historyTable = new Table(this, 'HistoryTable', { 59 | partitionKey: { name: 'userId', type: AttributeType.STRING }, 60 | sortKey: { name: 'timestamp', type: AttributeType.STRING }, 61 | billingMode: BillingMode.PAY_PER_REQUEST, 62 | removalPolicy: RemovalPolicy.DESTROY, 63 | encryption: TableEncryption.AWS_MANAGED, 64 | }); 65 | 66 | const reportTable = new Table(this, 'ReportTable', { 67 | partitionKey: { name: 'userId', type: AttributeType.STRING }, 68 | sortKey: { name: 'sortKey', type: AttributeType.STRING }, 69 | billingMode: BillingMode.PAY_PER_REQUEST, 70 | removalPolicy: RemovalPolicy.DESTROY, 71 | encryption: TableEncryption.AWS_MANAGED, 72 | }); 73 | 74 | const reportTableGlobalSecondaryIndex: GlobalSecondaryIndexProps = { 75 | indexName: 'ReportIdIndex', 76 | partitionKey: { 77 | name: 'reportId', 78 | type: AttributeType.STRING, 79 | }, 80 | }; 81 | 82 | reportTable.addGlobalSecondaryIndex(reportTableGlobalSecondaryIndex); 83 | 84 | this.historyTable = historyTable; 85 | this.reportTable = reportTable; 86 | this.reportTableReportIdIndex = reportTableGlobalSecondaryIndex; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /backend/lib/constructs/network.ts: -------------------------------------------------------------------------------- 1 | import { Peer, Port, SecurityGroup, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2'; 2 | import { Construct } from 'constructs'; 3 | 4 | export class Network extends Construct { 5 | readonly vpc: Vpc; 6 | readonly redshiftSecurityGroup: SecurityGroup; 7 | readonly lambdaSecurityGroup: SecurityGroup; 8 | 9 | constructor(scope: Construct, id: string) { 10 | super(scope, id); 11 | 12 | const vpc = new Vpc(this, 'Vpc', { 13 | subnetConfiguration: [ 14 | { 15 | name: "public", 16 | subnetType: SubnetType.PUBLIC, 17 | mapPublicIpOnLaunch: false 18 | }, 19 | { 20 | name: "private", 21 | subnetType: SubnetType.PRIVATE_WITH_EGRESS, 22 | }, 23 | ], 24 | }); 25 | 26 | const redshiftSecurityGroup = new SecurityGroup( 27 | this, 28 | 'RedshiftSecurityGroup', 29 | { 30 | vpc: vpc, 31 | allowAllOutbound: true, 32 | securityGroupName: 'RedshiftSecurityGroup', 33 | } 34 | ); 35 | 36 | const lambdaSecurityGroup = new SecurityGroup(this, 'LambdaSecurityGroup', { 37 | vpc: vpc, 38 | allowAllOutbound: true, 39 | securityGroupName: 'LambdaSecurityGroup', 40 | }); 41 | 42 | redshiftSecurityGroup.addIngressRule(lambdaSecurityGroup, Port.tcp(5439)); 43 | 44 | this.vpc = vpc; 45 | this.redshiftSecurityGroup = redshiftSecurityGroup; 46 | this.lambdaSecurityGroup = lambdaSecurityGroup; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.1.0", 4 | "bin": { 5 | "backend": "bin/backend.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.4.0", 15 | "@types/node": "18.14.6", 16 | "aws-cdk": "2.70.0", 17 | "jest": "^29.5.0", 18 | "ts-jest": "^29.0.5", 19 | "ts-node": "^10.9.1", 20 | "typescript": "~4.9.5" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "^2.70.0-alpha.0", 24 | "@aws-cdk/aws-redshift-alpha": "^2.70.0-alpha.0", 25 | "aws-cdk-lib": "2.80.0", 26 | "constructs": "^10.0.0", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/test/backend.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Backend from '../lib/backend-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/backend-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Backend.BackendStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020" 7 | ], 8 | "declaration": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": false, 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "experimentalDecorators": true, 21 | "strictPropertyInitialization": false, 22 | "typeRoots": [ 23 | "./node_modules/@types" 24 | ] 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "cdk.out" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /docs/BACKEND.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | ## API 4 | 5 | | Path | Method | Description | 6 | | ------------------------ | ------ | --------------------------------------------------------------------------- | 7 | | /export | POST | Export data in CSV format | 8 | | /extract | POST | Extract data from the database by specifying conditions | 9 | | /extract/history | GET | Get user's extraction history | 10 | | /report | GET | Get a list of saved reports (`?type=my`) or shared reports (`?type=shared`) | 11 | | /report | POST | Save a report | 12 | | /report/{reportId} | GET | Get a report by specifying a report ID | 13 | | /report/share/{reportId} | POST | Share a report with another user | 14 | | /table | GET | Get a list of table names | 15 | | /table/{tableName} | GET | Get information about a specific table | 16 | | /user | GET | Get a user information | 17 | 18 | ## Database (DynamoDB) 19 | 20 | ### History Table 21 | 22 | | Type | Name | Example | Description | 23 | | ---- | ------------ | ---------------------------------------------------------------------------------- | --------------------- | 24 | | PK | userId | 4195cabc-d549-4922-bcdd-26937c00d833 | User ID | 25 | | SK | timestamp | 2023-04-04T08:02:09.755826 | Extraction time | 26 | | | columns | ["column_a", "column_b"] | Extracted columns | 27 | | | conditions | [{"type": "string", "columnName": "name", "operator": "contains" "value": "Mike"}] | Extraction conditions | 28 | | | extractionId | 3a99e80b-c437-4daa-acff-a222c2f7de94 | Extraction ID | 29 | | | tableName | table_A | Table name | 30 | 31 | ### Report Table 32 | 33 | | Type | Name | Example | Description | 34 | | ---- | ------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | 35 | | PK | userId | 4195cabc-d549-4922-bcdd-26937c00d833 | [save] User ID to save a report
[share] User ID receiving a shared report | 36 | | SK | sortKey | timestamp#2023-04-04T11:35:03.235411
sharing#3368621b-8719-409c-a01f-4cc037db7f45 | [save] Report save time
[share] Shared report ID | 37 | | | columns | ["column_a", "column_b"] | [save] Column names | 38 | | | conditions | [{"type": "string", "columnName": "name", "operator": "contains" "value": "Mike"}] | [save] Conditions | 39 | | | reportId | cb409a4e-f127-4a5c-8674-3cd5da92c52f | [save] Report ID | 40 | | | reportName | report A | [save] Report name | 41 | | | tableName | table_A | [save] Table name | 42 | | | sharingUserId | e3c9850d-bd58-4662-8788-1f50c0595f22 | [share] User ID to share a report | 43 | 44 | ### Report Table (GSI) 45 | 46 | | Type | Name | Example | Description | 47 | | ----------------------------------------------------- | -------- | ------------------------------------ | ----------- | 48 | | PK | reportId | cb409a4e-f127-4a5c-8674-3cd5da92c52f | Report ID | 49 | | The following attributes are the same as Report Table | 50 | -------------------------------------------------------------------------------- /docs/FRONTEND.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | ## Overview 4 | 5 | It is a Single Page Application (SPA) using React and the build tool is [Vite](https://ja.vitejs.dev/). 6 | 7 | Authentication is implemented with [Amplify UI Component Authenticator](https://ui.docs.amplify.aws/react/connected-components/authenticator). 8 | If you are using Vite as your build tool, the following settings are required. 9 | 10 | - Add the following to `vite.config` 11 | 12 | ```json 13 | resolve: { alias: { "./runtimeConfig": "./runtimeConfig.browser" } } 14 | ``` 15 | 16 | - Add the following to `index.html` 17 | 18 | ```html 19 | 24 | ``` 25 | 26 | ## Local development 27 | 28 | Set up the development environment on the local machine. 29 | 30 | ### Deploy Backend 31 | 32 | Deploy the backend as described in [DEPLOY MANUAL](DEPLOYMENT.md). 33 | Authentication and backend APIs available after deployment. 34 | 35 | ### Start Local Frontend Server 36 | 37 | #### Environment variables 38 | 39 | Input the environment information output when the backend is deployed in `frontend/.env`. 40 | These outputs can also be viewed from the "Outputs" tab of the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/). 41 | 42 | ```bash 43 | # Backend API endpoint 44 | # This is BASE URL for API call. 45 | VITE_API_URL=https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/api 46 | 47 | # Cognito User Pool ID 48 | VITE_AUTH_USER_POOL_ID=ap-northeast-1_xxxxx 49 | 50 | # Cognito User Pool Client ID 51 | VITE_AUTH_WEB_CLIENT_ID=xxxxx 52 | ``` 53 | 54 | Install npm packages by entering the following command in a terminal 55 | 56 | ```bash 57 | cd frontend/ 58 | npm ci 59 | ``` 60 | 61 | After the npm package has been successfully installed, start the frontend server with the following command. 62 | 63 | ```bash 64 | npm run dev 65 | ``` 66 | 67 | If the following is displayed, the frontend server is running. 68 | Access the displayed URL from your browser. 69 | 70 | ```bash 71 | VITE v4.2.0 ready in 281 ms 72 | 73 | ➜ Local: http://localhost:5173/ 74 | ➜ Network: use --host to expose 75 | ➜ press h to show help 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/imgs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-data-exporter/9b52e51d1429760c3310d7468d75f510bd99563b/docs/imgs/architecture.png -------------------------------------------------------------------------------- /docs/imgs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-data-exporter/9b52e51d1429760c3310d7468d75f510bd99563b/docs/imgs/demo.gif -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "overrides": [], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": ["react", "@typescript-eslint"], 18 | "settings": { 19 | "react": { 20 | "version": "detect" 21 | } 22 | }, 23 | "ignorePatterns": ["dist/*", "**/jest.config.js", "cdk/cdk.out/*"], 24 | "rules": {} 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | cd frontend 5 | npm run format 6 | npm run lint 7 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | cdk/cdk.out 3 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "organizeImportsSkipDestructiveCodeActions": true 3 | } 4 | -------------------------------------------------------------------------------- /frontend/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /frontend/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /frontend/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | - `npm run build` compile typescript to js 10 | - `npm run watch` watch for changes and compile 11 | - `npm run test` perform the jest unit tests 12 | - `cdk deploy` deploy this stack to your default AWS account/region 13 | - `cdk diff` compare deployed stack with current state 14 | - `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /frontend/cdk/bin/frontend.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as cdk from "aws-cdk-lib"; 3 | import "source-map-support/register"; 4 | import { FrontendStack } from "../lib/frontend-stack"; 5 | import { FrontendWafStack } from "../lib/frontend-waf-stack"; 6 | 7 | const app = new cdk.App(); 8 | 9 | // set backend info 10 | const backendApiUrl = app.node.tryGetContext("backendApiUrl"); 11 | const userPoolId = app.node.tryGetContext("userPoolId"); 12 | const userPoolClientId = app.node.tryGetContext("userPoolClientId"); 13 | const allowedIpV4AddressRanges: string[] = app.node.tryGetContext( 14 | "allowedIpV4AddressRanges" 15 | ); 16 | const allowedIpV6AddressRanges: string[] = app.node.tryGetContext( 17 | "allowedIpV6AddressRanges" 18 | ); 19 | 20 | // WAF for frontend 21 | // 2023/4: Currently, the WAF for CloudFront needs to be created in the North America region (us-east-1), so the stacks are separated 22 | // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-webacl.html 23 | const waf = new FrontendWafStack(app, "DataExporterFrontendWafStack", { 24 | env: { 25 | region: "us-east-1", 26 | }, 27 | allowedIpV4AddressRanges, 28 | allowedIpV6AddressRanges, 29 | }); 30 | 31 | new FrontendStack(app, "DataExporterFrontendStack", { 32 | env: { 33 | region: "ap-northeast-1", 34 | }, 35 | crossRegionReferences: true, 36 | backendApiUrl, 37 | userPoolId, 38 | userPoolClientId, 39 | webAclId: waf.webAclArn.value, 40 | }); 41 | -------------------------------------------------------------------------------- /frontend/cdk/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/frontend.ts", 3 | "watch": { 4 | "include": ["**"], 5 | "exclude": [ 6 | "README.md", 7 | "cdk*.json", 8 | "**/*.d.ts", 9 | "**/*.js", 10 | "tsconfig.json", 11 | "package*.json", 12 | "yarn.lock", 13 | "node_modules", 14 | "test" 15 | ] 16 | }, 17 | "context": { 18 | "backendApiUrl": "https://7y0sdj74ye.execute-api.ap-northeast-1.amazonaws.com/api/", 19 | "userPoolId": "ap-northeast-1_3UKzHN9EJ", 20 | "userPoolClientId": "ap-northeast-1_3UKzHN9EJ", 21 | "allowedIpV4AddressRanges": ["0.0.0.0/1", "128.0.0.0/1"], 22 | "allowedIpV6AddressRanges": [ 23 | "0000:0000:0000:0000:0000:0000:0000:0000/1", 24 | "8000:0000:0000:0000:0000:0000:0000:0000/1" 25 | ], 26 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 27 | "@aws-cdk/core:checkSecretUsage": true, 28 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], 29 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 30 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 31 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 32 | "@aws-cdk/aws-iam:minimizePolicies": true, 33 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 34 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 35 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 36 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 37 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 38 | "@aws-cdk/core:enablePartitionLiterals": true, 39 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 40 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 41 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 42 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 43 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 44 | "@aws-cdk/aws-route53-patters:useCertificate": true, 45 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 46 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 47 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 48 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 49 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 50 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 51 | "@aws-cdk/aws-redshift:columnId": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | roots: ["/test"], 4 | testMatch: ["**/*.test.ts"], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/cdk/lib/frontend-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import { RemovalPolicy } from "aws-cdk-lib"; 3 | import { BlockPublicAccess, BucketEncryption } from "aws-cdk-lib/aws-s3"; 4 | import { Construct } from "constructs"; 5 | import path = require("path"); 6 | // import * as sqs from 'aws-cdk-lib/aws-sqs'; 7 | 8 | type FrontendStackProps = cdk.StackProps & { 9 | backendApiUrl: string; 10 | userPoolId: string; 11 | userPoolClientId: string; 12 | webAclId: string; 13 | }; 14 | 15 | export class FrontendStack extends cdk.Stack { 16 | constructor(scope: Construct, id: string, props: FrontendStackProps) { 17 | super(scope, id, props); 18 | 19 | // S3 Bucket for Frontend 20 | const websiteBucket = new cdk.aws_s3.Bucket(this, "FrontendBucket", { 21 | encryption: BucketEncryption.S3_MANAGED, 22 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 23 | removalPolicy: RemovalPolicy.DESTROY, 24 | }); 25 | 26 | const websiteIdentity = new cdk.aws_cloudfront.OriginAccessIdentity( 27 | this, 28 | "WebsiteIdentity" 29 | ); 30 | // grant read permission to hosting SPA 31 | websiteBucket.grantRead(websiteIdentity); 32 | 33 | const distribution = new cdk.aws_cloudfront.CloudFrontWebDistribution( 34 | this, 35 | "FrontendDist", 36 | { 37 | // Return index.html as response even if 404 error occurs. 38 | // 404 error control is implemented in the frontend. 39 | errorConfigurations: [ 40 | { 41 | errorCachingMinTtl: 300, 42 | errorCode: 404, 43 | responseCode: 200, 44 | responsePagePath: "/index.html", 45 | }, 46 | ], 47 | 48 | // Distribute S3 Bucket as origin 49 | originConfigs: [ 50 | { 51 | s3OriginSource: { 52 | s3BucketSource: websiteBucket, 53 | originAccessIdentity: websiteIdentity, 54 | }, 55 | behaviors: [ 56 | { 57 | isDefaultBehavior: true, 58 | }, 59 | ], 60 | }, 61 | ], 62 | 63 | // define WAF 64 | webACLId: props.webAclId, 65 | } 66 | ); 67 | 68 | // Deploy React App to S3 Bucket 69 | new cdk.aws_s3_deployment.BucketDeployment(this, "ReactDeploy", { 70 | sources: [ 71 | cdk.aws_s3_deployment.Source.asset(path.join(__dirname, "../../"), { 72 | // Setup to build the frontend 73 | bundling: { 74 | // Configure if build output is not a zip file 75 | outputType: cdk.BundlingOutput.NOT_ARCHIVED, 76 | // Specify the Docker container image to build 77 | image: cdk.DockerImage.fromRegistry( 78 | "public.ecr.aws/docker/library/node:16-bullseye" 79 | ), 80 | // Environment variables at build time 81 | environment: { 82 | // Backend API URL 83 | VITE_API_URL: props.backendApiUrl, 84 | // Cognit user pool ID 85 | VITE_AUTH_USER_POOL_ID: props.userPoolId, 86 | // Cognito user pool client ID 87 | VITE_AUTH_WEB_CLIENT_ID: props.userPoolClientId, 88 | }, 89 | // User in Docker container at build time 90 | user: "node", 91 | // Build command 92 | command: [ 93 | "bash", 94 | "-c", 95 | [ 96 | "npm ci --loglevel=error", 97 | "npm run build", 98 | "cp -r dist/* /asset-output/", 99 | ].join(" && "), 100 | ], 101 | }, 102 | }), 103 | ], 104 | destinationBucket: websiteBucket, 105 | distribution: distribution, 106 | distributionPaths: ["/*"], 107 | retainOnDelete: false, 108 | }); 109 | 110 | // output CloudFront URL 111 | new cdk.CfnOutput(this, "CloudFrontURL", { 112 | description: "CloudFrontURL", 113 | value: `https://${distribution.distributionDomainName}`, 114 | }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /frontend/cdk/lib/frontend-waf-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import { CfnOutput, Stack, StackProps } from "aws-cdk-lib"; 3 | import * as wafv2 from "aws-cdk-lib/aws-wafv2"; 4 | import { Construct } from "constructs"; 5 | 6 | interface FrontendWafStackProps extends StackProps { 7 | allowedIpV4AddressRanges: string[]; 8 | allowedIpV6AddressRanges: string[]; 9 | } 10 | 11 | /** 12 | * Frontend WAF 13 | */ 14 | export class FrontendWafStack extends Stack { 15 | /** 16 | * Web ACL ARN 17 | */ 18 | public readonly webAclArn: CfnOutput; 19 | 20 | constructor(scope: Construct, id: string, props: FrontendWafStackProps) { 21 | super(scope, id, props); 22 | 23 | // create Ipset for ACL 24 | const ipV4SetReferenceStatement = new wafv2.CfnIPSet( 25 | this, 26 | "DataExporterFrontendIpV4Set", 27 | { 28 | ipAddressVersion: "IPV4", 29 | scope: "CLOUDFRONT", 30 | addresses: props.allowedIpV4AddressRanges, 31 | } 32 | ); 33 | const ipV6SetReferenceStatement = new wafv2.CfnIPSet( 34 | this, 35 | "DataExporterFrontendIpV6Set", 36 | { 37 | ipAddressVersion: "IPV6", 38 | scope: "CLOUDFRONT", 39 | addresses: props.allowedIpV6AddressRanges, 40 | } 41 | ); 42 | 43 | const webAcl = new wafv2.CfnWebACL(this, "WebAcl", { 44 | defaultAction: { block: {} }, 45 | name: "DataExporterFrontendWebAcl", 46 | scope: "CLOUDFRONT", 47 | visibilityConfig: { 48 | cloudWatchMetricsEnabled: true, 49 | metricName: "FrontendWebAcl", 50 | sampledRequestsEnabled: true, 51 | }, 52 | rules: [ 53 | { 54 | priority: 0, 55 | name: "FrontendWebAclIpV4RuleSet", 56 | action: { allow: {} }, 57 | visibilityConfig: { 58 | cloudWatchMetricsEnabled: true, 59 | metricName: "FrontendWebAcl", 60 | sampledRequestsEnabled: true, 61 | }, 62 | statement: { 63 | ipSetReferenceStatement: { arn: ipV4SetReferenceStatement.attrArn }, 64 | }, 65 | }, 66 | { 67 | priority: 1, 68 | name: "FrontendWebAclIpV6RuleSet", 69 | action: { allow: {} }, 70 | visibilityConfig: { 71 | cloudWatchMetricsEnabled: true, 72 | metricName: "FrontendWebAcl", 73 | sampledRequestsEnabled: true, 74 | }, 75 | statement: { 76 | ipSetReferenceStatement: { arn: ipV6SetReferenceStatement.attrArn }, 77 | }, 78 | }, 79 | ], 80 | }); 81 | 82 | this.webAclArn = new cdk.CfnOutput(this, "WebAclId", { 83 | value: webAcl.attrArn, 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.4.0", 15 | "@types/node": "18.14.6", 16 | "jest": "^29.5.0", 17 | "ts-jest": "^29.0.5", 18 | "aws-cdk": "2.69.0", 19 | "ts-node": "^10.9.1", 20 | "typescript": "~4.9.5" 21 | }, 22 | "dependencies": { 23 | "aws-cdk-lib": "2.80.0", 24 | "constructs": "^10.0.0", 25 | "source-map-support": "^0.5.21" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test("SQS Queue Created", () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | // template.hasResourceProperties('AWS::SQS::Queue', { 14 | // VisibilityTimeout: 300 15 | // }); 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "declaration": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": false, 16 | "inlineSourceMap": true, 17 | "inlineSources": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "typeRoots": ["./node_modules/@types"] 21 | }, 22 | "exclude": ["node_modules", "cdk.out"] 23 | } 24 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "prepare": "cd .. && husky install frontend/.husky", 11 | "lint": "npx eslint .", 12 | "format": "npx prettier --write ." 13 | }, 14 | "dependencies": { 15 | "@aws-amplify/ui-react": "^4.4.1", 16 | "@emotion/react": "^11.10.6", 17 | "@emotion/styled": "^11.10.6", 18 | "@mui/icons-material": "^5.11.11", 19 | "@mui/lab": "^5.0.0-alpha.124", 20 | "@mui/material": "^5.11.13", 21 | "@mui/x-date-pickers": "^6.5.0", 22 | "aws-amplify": "^5.3.5", 23 | "axios": "^1.3.4", 24 | "dayjs": "^1.11.7", 25 | "i18next": "^22.5.0", 26 | "i18next-browser-languagedetector": "^7.0.1", 27 | "immutability-helper": "^3.1.1", 28 | "material-react-table": "^1.9.1", 29 | "react": "^18.2.0", 30 | "react-dnd": "^16.0.1", 31 | "react-dnd-html5-backend": "^16.0.1", 32 | "react-dom": "^18.2.0", 33 | "react-i18next": "^12.3.1", 34 | "react-loader-spinner": "^5.3.4", 35 | "react-router-dom": "^6.9.0", 36 | "recoil": "^0.7.7", 37 | "recoil-persist": "^4.2.0", 38 | "swr": "^2.1.1" 39 | }, 40 | "devDependencies": { 41 | "@types/react": "^18.0.28", 42 | "@types/react-dom": "^18.0.11", 43 | "@typescript-eslint/eslint-plugin": "^5.55.0", 44 | "@typescript-eslint/parser": "^5.55.0", 45 | "@vitejs/plugin-react": "^3.1.0", 46 | "eslint": "^8.36.0", 47 | "eslint-plugin-react": "^7.32.2", 48 | "husky": "^8.0.3", 49 | "prettier": "2.8.4", 50 | "prettier-plugin-organize-imports": "^3.2.2", 51 | "typescript": "^4.9.3", 52 | "vite": "^4.2.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/@types/myPage.d.ts: -------------------------------------------------------------------------------- 1 | import { ExtractCondition } from "./report"; 2 | 3 | export type MenuEnum = "saved" | "shared" | "history"; 4 | 5 | export type UserProfile = { 6 | downloadable: boolean; 7 | email: string; 8 | }; 9 | 10 | export type ExtractHistoryListItem = { 11 | tableName: string; 12 | extractionTime: string; 13 | columns: string[]; 14 | conditions: ExtractCondition[]; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/@types/react-i18next.d.ts: -------------------------------------------------------------------------------- 1 | import "react-i18next"; 2 | import en from "../i18n/en"; 3 | declare module "i18next" { 4 | interface CustomTypeOptions { 5 | resources: typeof en; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/@types/report.d.ts: -------------------------------------------------------------------------------- 1 | export type TableMeta = { 2 | tableName: string; 3 | tableId: string; 4 | }; 5 | 6 | export type TableColomnType = "string" | "number" | "date"; 7 | 8 | export type TableColumn = { 9 | name: string; 10 | type: TableColomnType; 11 | }; 12 | 13 | export type ConditionType = 14 | | "string" 15 | | "number" 16 | | "absoluteDate" 17 | | "relativeDate" 18 | | "relativeDateN" 19 | | "time"; 20 | 21 | export type ConditionOperator = 22 | | "eq" 23 | | "neq" 24 | | "gt" 25 | | "gte" 26 | | "lt" 27 | | "lte" 28 | | "contains"; 29 | 30 | export type RelativeDateNPeriod = "day" | "week" | "month" | "year"; 31 | export type RelativeDatePeriod = 32 | | "today" 33 | | "yesterday" 34 | | "lastWeek" 35 | | "lastMonth" 36 | | "lastYear"; 37 | 38 | export type ExtractCondition = { 39 | columnName: string; 40 | } & ( 41 | | { 42 | type: "string"; 43 | operator: ConditionOperator; 44 | value: string | null; 45 | } 46 | | { 47 | type: "number"; 48 | operator: ConditionOperator; 49 | value: number | null; 50 | } 51 | | { 52 | type: "absoluteDate"; 53 | startDate: string; 54 | endDate: string; 55 | } 56 | | { 57 | type: "relativeDateN"; 58 | n: number; 59 | period: RelativeDateNPeriod; 60 | } 61 | | { 62 | type: "relativeDate"; 63 | period: RelativeDatePeriod; 64 | } 65 | | { 66 | type: "time"; 67 | startTime: string; 68 | endTime: string; 69 | } 70 | ); 71 | 72 | export type Report = { 73 | reportId: string; 74 | reportName: string; 75 | tableName: string; 76 | conditions: ExtractCondition[]; 77 | columns: string[]; 78 | reportTime: strinf; 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, useEffect } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Route, Routes } from "react-router-dom"; 4 | import RequiresAuth from "./features/auth/components/RequiresAuth"; 5 | 6 | const MyPage = lazy(() => import("./features/myPage/pages/MyPage")); 7 | const Layout = lazy(() => import("./components/Layout")); 8 | const ReportConditionsPage = lazy( 9 | () => import("./features/report/pages/ReportConditionsPage") 10 | ); 11 | const ReportExtractPage = lazy( 12 | () => import("./features/report/pages/ReportExtractPage") 13 | ); 14 | 15 | /** 16 | * Main page for Application 17 | * Implement routing, common process for application 18 | * @returns 19 | */ 20 | const App: React.FC = () => { 21 | const { t } = useTranslation(); 22 | 23 | useEffect(() => { 24 | // set header title 25 | document.title = t("app.name"); 26 | }, []); 27 | 28 | return ( 29 | 30 | {/* Pages that require authentication are set as child elements of RequiresAuth. */} 31 | }> 32 | }> 33 | } /> 34 | } 37 | /> 38 | } 41 | /> 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /frontend/src/api/useExportApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { useTranslation } from "react-i18next"; 3 | import useAlertSnackbar from "../hooks/useAlertSnackbar"; 4 | import useHttp from "../hooks/useHttp"; 5 | 6 | export type ExportParams = { 7 | columns: string[]; 8 | table: string[][]; 9 | }; 10 | 11 | const useExportApi = () => { 12 | const http = useHttp(); 13 | const alert = useAlertSnackbar(); 14 | const { t } = useTranslation(); 15 | 16 | return { 17 | exportData: (params: ExportParams) => { 18 | const errorProcess = (error: AxiosError) => { 19 | if (error.response?.status === 403) { 20 | alert.openError(t("app.error.downloadDenied")); 21 | } else { 22 | alert.openError(error.message); 23 | } 24 | }; 25 | return http.post("/export", params, errorProcess); 26 | }, 27 | }; 28 | }; 29 | 30 | export default useExportApi; 31 | -------------------------------------------------------------------------------- /frontend/src/api/useExtractionApi.ts: -------------------------------------------------------------------------------- 1 | import { ExtractCondition } from "../@types/report"; 2 | import useHttp from "../hooks/useHttp"; 3 | 4 | export type ExtractionHistoryResponse = { 5 | historyRecords: { 6 | tableName: string; 7 | conditions: ExtractCondition[]; 8 | columns: string[]; 9 | extractionTime: string; 10 | }[]; 11 | }; 12 | 13 | export type ExtractDataParams = { 14 | tableName: string; 15 | columns: string[]; 16 | conditions: ExtractCondition[]; 17 | }; 18 | export type ExtractDataResponse = { 19 | items: string[][]; 20 | }; 21 | 22 | const useExtractionApi = () => { 23 | const http = useHttp(); 24 | 25 | return { 26 | getExtractionHistory: () => { 27 | return http.get("/extract/history"); 28 | }, 29 | extractData: (params: ExtractDataParams) => { 30 | return http.post("/extract", params); 31 | }, 32 | }; 33 | }; 34 | 35 | export default useExtractionApi; 36 | -------------------------------------------------------------------------------- /frontend/src/api/useReportApi.ts: -------------------------------------------------------------------------------- 1 | import { ExtractCondition, Report } from "../@types/report"; 2 | import useHttp from "../hooks/useHttp"; 3 | 4 | export type GetReportListResponse = { 5 | report: Report[]; 6 | }; 7 | 8 | export type GetReportResponse = { 9 | report: Report; 10 | }; 11 | 12 | export type RegisterReportParams = { 13 | reportName: string; 14 | tableName: string; 15 | columns: string[]; 16 | conditions: ExtractCondition[]; 17 | }; 18 | 19 | export type RegisterReportResponse = { 20 | reportId: string; 21 | }; 22 | 23 | const useReportApi = () => { 24 | const http = useHttp(); 25 | 26 | return { 27 | getMyReportList: () => { 28 | return http.get("/report?type=my"); 29 | }, 30 | getSharedReportList: () => { 31 | return http.get("/report?type=shared"); 32 | }, 33 | getReportById: (reportId?: string) => { 34 | return http.get( 35 | reportId ? `/report/${reportId}` : null 36 | ); 37 | }, 38 | registerReport: (params: RegisterReportParams) => { 39 | return http.post("/report", { 40 | ...params, 41 | }); 42 | }, 43 | shareReport: (reportId: string, sharedUserId: string) => { 44 | return http.post(`report/share/${reportId}`, { 45 | sharedUserEmail: sharedUserId, 46 | }); 47 | }, 48 | }; 49 | }; 50 | 51 | export default useReportApi; 52 | -------------------------------------------------------------------------------- /frontend/src/api/useTableApi.ts: -------------------------------------------------------------------------------- 1 | import { TableColomnType } from "../@types/report"; 2 | import useHttp from "../hooks/useHttp"; 3 | 4 | export type GetTableListResponse = { 5 | tables: string[]; 6 | }; 7 | 8 | export type GetTableColumnsResponse = { 9 | name: string; 10 | columns: { 11 | name: string; 12 | type: TableColomnType; 13 | }[]; 14 | }; 15 | 16 | const useTableApi = () => { 17 | const http = useHttp(); 18 | 19 | return { 20 | getTableList: () => { 21 | return http.get("/table"); 22 | }, 23 | getTableColumns: (tableName: string) => { 24 | return http.get( 25 | tableName ? `/table/${tableName}` : null 26 | ); 27 | }, 28 | }; 29 | }; 30 | 31 | export default useTableApi; 32 | -------------------------------------------------------------------------------- /frontend/src/api/useUserApi.ts: -------------------------------------------------------------------------------- 1 | import useHttp from "../hooks/useHttp"; 2 | 3 | export type GetUserResponse = { 4 | downloadable: boolean; 5 | email: string; 6 | }; 7 | 8 | const useUserApi = () => { 9 | const http = useHttp(); 10 | 11 | return { 12 | getUser: () => { 13 | return http.get("/user"); 14 | }, 15 | }; 16 | }; 17 | 18 | export default useUserApi; 19 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/AppDrawer.tsx: -------------------------------------------------------------------------------- 1 | import AccountCircleIcon from "@mui/icons-material/AccountCircle"; 2 | import EditIcon from "@mui/icons-material/Edit"; 3 | import { 4 | Drawer, 5 | List, 6 | ListItem, 7 | ListItemButton, 8 | ListItemIcon, 9 | ListItemText, 10 | } from "@mui/material"; 11 | import { Box } from "@mui/system"; 12 | import React, { useCallback, useMemo } from "react"; 13 | import { useTranslation } from "react-i18next"; 14 | import { useNavigate } from "react-router-dom"; 15 | import { useRecoilState } from "recoil"; 16 | import useReportState from "../features/report/hooks/useReportState"; 17 | import DrawerState from "../recoil/DrawerState"; 18 | 19 | type DrawerListItem = { 20 | label: string; 21 | to: string; 22 | icon: React.ReactNode; 23 | before?: () => void; 24 | }; 25 | 26 | /** 27 | * Side menu drawer 28 | * @returns 29 | */ 30 | const AppDrawer: React.FC = () => { 31 | const { clear } = useReportState(); 32 | const { t, i18n } = useTranslation(); 33 | 34 | const listItems: DrawerListItem[] = useMemo( 35 | () => [ 36 | { 37 | label: t("app.sideMenu.myPage"), 38 | to: "/", 39 | icon: , 40 | }, 41 | { 42 | label: t("app.sideMenu.newReport"), 43 | to: "/report/conditions/new", 44 | icon: , 45 | before: () => { 46 | clear(); 47 | }, 48 | }, 49 | ], 50 | [i18n.language] 51 | ); 52 | 53 | const [open, setOpen] = useRecoilState(DrawerState.open); 54 | const navigate = useNavigate(); 55 | 56 | const onClick = useCallback((item: DrawerListItem) => { 57 | if (item.before) { 58 | item.before(); 59 | } 60 | navigate(item.to); 61 | setOpen(false); 62 | }, []); 63 | 64 | return ( 65 | { 69 | setOpen(false); 70 | }} 71 | > 72 | 73 | 74 | {listItems.map((item, index) => ( 75 | 76 | onClick(item)}> 77 | {item.icon} 78 | 79 | 80 | 81 | ))} 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default AppDrawer; 89 | -------------------------------------------------------------------------------- /frontend/src/components/AppHeader.tsx: -------------------------------------------------------------------------------- 1 | import Brightness4Icon from "@mui/icons-material/Brightness4"; 2 | import Brightness7Icon from "@mui/icons-material/Brightness7"; 3 | import LanguageIcon from "@mui/icons-material/Language"; 4 | import MenuIcon from "@mui/icons-material/Menu"; 5 | import PersonIcon from "@mui/icons-material/Person"; 6 | import { 7 | AppBar, 8 | Avatar, 9 | Box, 10 | Divider, 11 | IconButton, 12 | Menu, 13 | MenuItem, 14 | Toolbar, 15 | Typography, 16 | } from "@mui/material"; 17 | import React, { useCallback, useState } from "react"; 18 | import { useTranslation } from "react-i18next"; 19 | import { useNavigate } from "react-router-dom"; 20 | import { useRecoilState } from "recoil"; 21 | import useMuiTheme from "../hooks/useMuiTheme"; 22 | import DrawerState from "../recoil/DrawerState"; 23 | import DialogSelectLanguage from "./DialogSelectLanguage"; 24 | type Props = { 25 | signOut: () => void; 26 | }; 27 | 28 | /** 29 | * Application Header 30 | * @param param0 31 | * @returns 32 | */ 33 | const AppHeader: React.FC = ({ signOut }) => { 34 | const [open, setOpen] = useRecoilState(DrawerState.open); 35 | const { t } = useTranslation(); 36 | 37 | const navigate = useNavigate(); 38 | const gotoTop = useCallback(() => { 39 | navigate("/"); 40 | }, []); 41 | 42 | const { switchMode, isDark } = useMuiTheme(); 43 | 44 | const [anchorEl, setAnchorEl] = React.useState(null); 45 | const openPerference = Boolean(anchorEl); 46 | 47 | const handleClick = (event: React.MouseEvent) => { 48 | setAnchorEl(event.currentTarget); 49 | }; 50 | const handleClose = () => { 51 | setAnchorEl(null); 52 | }; 53 | 54 | const [openLanguageDialog, setOpenLanguageDialog] = useState(false); 55 | 56 | const handleLanguageDialogOpen = useCallback(() => { 57 | setOpenLanguageDialog(true); 58 | }, []); 59 | const handleLanguageDialogClose = useCallback(() => { 60 | setOpenLanguageDialog(false); 61 | }, []); 62 | 63 | return ( 64 | 65 | 66 | 67 | {/* Open drawer when menu button pushed */} 68 | { 75 | setOpen(!open); 76 | }} 77 | > 78 | 79 | 80 | 81 | {/* Go to IndexPage when title clicked */} 82 | 93 | {t("app.name")} 94 | 95 | {/* filler */} 96 | 97 | 98 | 99 | 100 | 101 | 102 | 108 | { 110 | handleLanguageDialogOpen(); 111 | }} 112 | > 113 | {t("app.settings.language")} 114 | 115 | 116 | 117 | { 119 | switchMode(); 120 | }} 121 | > 122 | {t("app.settings.switchTheme")} 123 | {isDark ? ( 124 | 125 | ) : ( 126 | 127 | )} 128 | 129 | 130 | { 132 | signOut(); 133 | }} 134 | > 135 | {t("app.settings.signOut")} 136 | 137 | 138 | 139 | 140 | 144 | 145 | ); 146 | }; 147 | 148 | export default AppHeader; 149 | -------------------------------------------------------------------------------- /frontend/src/components/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import { DesktopDatePicker } from "@mui/x-date-pickers"; 2 | import dayjs from "dayjs"; 3 | import React from "react"; 4 | 5 | type Props = { 6 | value: string; 7 | onChange: (value: string) => void; 8 | }; 9 | 10 | const DatePicker: React.FC = ({ value, onChange }) => { 11 | return ( 12 | { 24 | onChange(newValue?.format() ?? ""); 25 | }} 26 | /> 27 | ); 28 | }; 29 | 30 | export default DatePicker; 31 | -------------------------------------------------------------------------------- /frontend/src/components/DialogSelectLanguage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | FormControl, 9 | FormControlLabel, 10 | FormLabel, 11 | Radio, 12 | RadioGroup, 13 | } from "@mui/material"; 14 | import React, { useState } from "react"; 15 | import { useTranslation } from "react-i18next"; 16 | import { LANGUAGES } from "../i18n"; 17 | 18 | type Props = { 19 | open: boolean; 20 | onClose: () => void; 21 | }; 22 | 23 | const DialogSelectLanguage: React.FC = ({ open, onClose }) => { 24 | const { t, i18n } = useTranslation(); 25 | 26 | const [language, setLanguage] = useState<(typeof LANGUAGES)[number]>( 27 | i18n.language as (typeof LANGUAGES)[number] 28 | ); 29 | 30 | const handleChangeLanguage = () => { 31 | i18n.changeLanguage(language); 32 | onClose(); 33 | }; 34 | 35 | return ( 36 | 37 | {t("app.selectLanguageDialog.title")} 38 | 39 | 40 | 41 | {t("app.selectLanguageDialog.label")} 42 | 45 | setLanguage(e.target.value as (typeof LANGUAGES)[number]) 46 | } 47 | > 48 | {LANGUAGES.map((lang) => ( 49 | } 53 | label={t(`app.language.${lang}`)} 54 | /> 55 | ))} 56 | 57 | 58 | 59 | 60 | 61 | 64 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default DialogSelectLanguage; 80 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Toolbar } from "@mui/material"; 2 | import React, { Suspense } from "react"; 3 | import { Outlet } from "react-router-dom"; 4 | import useAuth from "../hooks/useAuth"; 5 | import AppDrawer from "./AppDrawer"; 6 | import AppHeader from "./AppHeader"; 7 | import Loading from "./Loading"; 8 | 9 | /** 10 | * Page layout 11 | * @param param0 12 | * @returns 13 | */ 14 | const Layout: React.FC = () => { 15 | const { signOut } = useAuth(); 16 | 17 | return ( 18 | <> 19 | { 21 | signOut(); 22 | }} 23 | /> 24 | 25 | 26 | 27 | }> 28 | {/* Toolbar is margin for AppHeader */} 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default Layout; 38 | -------------------------------------------------------------------------------- /frontend/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material"; 2 | import React from "react"; 3 | import { ThreeDots } from "react-loader-spinner"; 4 | 5 | type Props = { 6 | color?: "primary" | "gray"; 7 | }; 8 | const Loading: React.FC = ({ color }) => { 9 | return ( 10 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default Loading; 24 | -------------------------------------------------------------------------------- /frontend/src/components/LoadingList.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@mui/material"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | count?: number; 6 | }; 7 | const LoadingList: React.FC = ({ count }) => { 8 | return ( 9 | <> 10 | {[...Array(count)].map((_, index) => ( 11 | 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | export default LoadingList; 22 | -------------------------------------------------------------------------------- /frontend/src/features/auth/components/RequiresAuth.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import { Outlet } from "react-router-dom"; 3 | import Loading from "../../../components/Loading"; 4 | import SignInPage from "../pages/SignInPage"; 5 | 6 | const RequiresAuth: React.FC = () => { 7 | return ( 8 | 9 | {/* Display Route child components after user is authenticated */} 10 | }> 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default RequiresAuth; 18 | -------------------------------------------------------------------------------- /frontend/src/features/auth/pages/SignInPage.tsx: -------------------------------------------------------------------------------- 1 | import { Amplify, I18n } from "@aws-amplify/core"; 2 | import { Authenticator, translations } from "@aws-amplify/ui-react"; 3 | import "@aws-amplify/ui-react/styles.css"; 4 | import { Box, Typography } from "@mui/material"; 5 | import React, { useCallback } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | import { LANGUAGES } from "../../../i18n"; 8 | 9 | type Props = { 10 | children: React.ReactNode; 11 | }; 12 | 13 | const SignInPage: React.FC = ({ children }) => { 14 | const { t, i18n } = useTranslation(); 15 | 16 | Amplify.configure({ 17 | // Cognito info 18 | Auth: { 19 | userPoolId: import.meta.env.VITE_AUTH_USER_POOL_ID, 20 | userPoolWebClientId: import.meta.env.VITE_AUTH_WEB_CLIENT_ID, 21 | authenticationFlowType: "USER_SRP_AUTH", 22 | }, 23 | }); 24 | 25 | // I18n settings for Amplify UI 26 | I18n.putVocabularies(translations); 27 | I18n.setLanguage(i18n.language); 28 | 29 | const AuthHeader = useCallback( 30 | () => ( 31 | 40 | {t("app.name")} 41 | 42 | ), 43 | [] 44 | ); 45 | 46 | const AuthFooter = useCallback( 47 | () => ( 48 | 56 | 57 | Language: 58 | 59 | 60 | {LANGUAGES.map((lang) => 61 | lang === i18n.language ? ( 62 | t(`app.language.${lang}`) 63 | ) : ( 64 | 65 | {t(`app.language.${lang}`)} 66 | 67 | ) 68 | ).map((item, index) => ( 69 | 70 | {item} 71 | {index < LANGUAGES.length - 1 ? ( 72 | 73 | / 74 | 75 | ) : null} 76 | 77 | ))} 78 | 79 | ), 80 | [] 81 | ); 82 | return ( 83 | 92 | {/* Display children components after user is authenticated */} 93 | {children} 94 | 95 | ); 96 | }; 97 | 98 | export default SignInPage; 99 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/CardProfile.tsx: -------------------------------------------------------------------------------- 1 | import PersonIcon from "@mui/icons-material/Person"; 2 | import { 3 | Avatar, 4 | Card, 5 | CardContent, 6 | Chip, 7 | Skeleton, 8 | Typography, 9 | } from "@mui/material"; 10 | import { Box } from "@mui/system"; 11 | import React, { useMemo } from "react"; 12 | import { useTranslation } from "react-i18next"; 13 | import { UserProfile } from "../../../@types/myPage"; 14 | 15 | type Props = { 16 | profile?: UserProfile; 17 | }; 18 | 19 | const CardProfile: React.FC = ({ profile }) => { 20 | const { t } = useTranslation(); 21 | const loading = useMemo(() => { 22 | return !profile; 23 | }, [profile]); 24 | 25 | return ( 26 | 27 | 28 | 33 | {loading ? ( 34 | 35 | ) : ( 36 | 37 | 38 | 39 | )} 40 | 41 | 42 | 43 | {loading ? ( 44 | <> 45 | 46 | 47 | ) : ( 48 | <> 49 | {profile?.email} 50 | 51 | )} 52 | 53 | 54 | {loading ? ( 55 | 56 | ) : ( 57 | <> 58 | {profile?.downloadable ? ( 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 | )} 65 | 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default CardProfile; 72 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/DialogShareReport.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle, 9 | TextField, 10 | } from "@mui/material"; 11 | import React, { useEffect, useState } from "react"; 12 | import { Trans, useTranslation } from "react-i18next"; 13 | 14 | type Props = { 15 | open: boolean; 16 | reportName: string; 17 | onRegister: (userId: string) => Promise; 18 | onClose: () => void; 19 | }; 20 | 21 | const DialogShareReport: React.FC = ({ 22 | open, 23 | reportName, 24 | onRegister, 25 | onClose, 26 | }) => { 27 | const [mail, setMail] = useState(""); 28 | const { t } = useTranslation(); 29 | 30 | useEffect(() => { 31 | setMail(""); 32 | }, []); 33 | 34 | return ( 35 | 36 | {t("myPage.shareReportDialog.title")} 37 | 38 | 39 | 40 | }} 44 | /> 45 | 46 | { 51 | setMail(e.target.value); 52 | }} 53 | /> 54 | 55 | 56 | 57 | 60 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default DialogShareReport; 81 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListExtractHistory.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, List } from "@mui/material"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import useExtractionApi from "../../../api/useExtractionApi"; 5 | import LoadingList from "../../../components/LoadingList"; 6 | import ListItemExportHistory from "./ListItemExtractHistory"; 7 | 8 | const ListExtractHistory: React.FC = () => { 9 | const { getExtractionHistory } = useExtractionApi(); 10 | const { t } = useTranslation(); 11 | 12 | const { data } = getExtractionHistory(); 13 | 14 | return ( 15 | 21 | 22 | {!data ? ( 23 | 24 | ) : data.historyRecords.length === 0 ? ( 25 | 33 | {t("myPage.extractionHistory.message.noHistory")} 34 | 35 | ) : ( 36 | data.historyRecords.map((history, idx) => ( 37 | 38 | 39 | 40 | 41 | )) 42 | )} 43 | 44 | ); 45 | }; 46 | 47 | export default ListExtractHistory; 48 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListItemExtractHistory.tsx: -------------------------------------------------------------------------------- 1 | import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; 2 | import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; 3 | import { 4 | Box, 5 | Collapse, 6 | Grid, 7 | ListItem, 8 | ListItemButton, 9 | ListItemText, 10 | SxProps, 11 | } from "@mui/material"; 12 | import { TFunction } from "i18next"; 13 | import React, { ReactNode, useMemo, useState } from "react"; 14 | import { Trans, useTranslation } from "react-i18next"; 15 | import { ExtractHistoryListItem } from "../../../@types/myPage"; 16 | import { ConditionOperator, ExtractCondition } from "../../../@types/report"; 17 | import useMuiTheme from "../../../hooks/useMuiTheme"; 18 | import { formatDatetime } from "../../../utils/DateUtils"; 19 | 20 | type Props = { 21 | history: ExtractHistoryListItem; 22 | }; 23 | 24 | const TextBlock: React.FC<{ column?: boolean; children?: ReactNode }> = ({ 25 | column, 26 | children, 27 | }) => { 28 | const { isDark } = useMuiTheme(); 29 | 30 | return ( 31 | 32 | 49 | {children} 50 | 51 | 52 | ); 53 | }; 54 | 55 | const getNumberOperator = (operator: ConditionOperator, t: TFunction) => { 56 | switch (operator) { 57 | case "eq": 58 | return t("app.numberOperator.eq"); 59 | case "neq": 60 | return t("app.numberOperator.neq"); 61 | case "gt": 62 | return t("app.numberOperator.gt"); 63 | case "gte": 64 | return t("app.numberOperator.gte"); 65 | case "lt": 66 | return t("app.numberOperator.lt"); 67 | case "lte": 68 | return t("app.numberOperator.lte"); 69 | } 70 | }; 71 | 72 | const renderStringOperator = ( 73 | operator: ConditionOperator, 74 | value: string, 75 | t: TFunction 76 | ): ReactNode => { 77 | switch (operator) { 78 | case "eq": 79 | if (value === "") { 80 | return <>{t("myPage.extractionHistory.stringOperator.eq.empty")}; 81 | } 82 | return ( 83 | }} 87 | /> 88 | ); 89 | case "neq": 90 | if (value === "") { 91 | return <>{t("myPage.extractionHistory.stringOperator.neq.empty")}; 92 | } 93 | return ( 94 | }} 98 | /> 99 | ); 100 | case "contains": 101 | return ( 102 | }} 106 | /> 107 | ); 108 | } 109 | }; 110 | 111 | const renderCondition = (condition: ExtractCondition, t: TFunction) => { 112 | return ( 113 | 114 | {condition.type === "number" ? ( 115 | <> 116 | {condition.columnName} 117 | {getNumberOperator(condition.operator, t)} 118 | {condition.value} 119 | 120 | ) : condition.type === "string" ? ( 121 | <> 122 | {condition.columnName} 123 | {renderStringOperator(condition.operator, condition.value ?? "", t)} 124 | 125 | ) : condition.type === "relativeDate" ? ( 126 | <> 127 | {condition.columnName} 128 | }} 131 | /> 132 | 133 | ) : condition.type === "relativeDateN" ? ( 134 | <> 135 | {condition.columnName} 136 | }} 141 | /> 142 | 143 | ) : condition.type === "absoluteDate" ? ( 144 | <> 145 | {condition.columnName} 146 | }} 153 | /> 154 | 155 | ) : null} 156 | 157 | ); 158 | }; 159 | 160 | const ListItemExtractHistory: React.FC = ({ history }) => { 161 | const { isDark } = useMuiTheme(); 162 | const { t, i18n } = useTranslation(); 163 | 164 | const [openDetail, setOpenDetail] = useState(false); 165 | 166 | const formattedDatetime = useMemo( 167 | () => formatDatetime(history.extractionTime), 168 | [history.extractionTime] 169 | ); 170 | 171 | const headerStyle = useMemo(() => { 172 | return { 173 | fontWeight: "bold", 174 | minWidth: "7rem", 175 | color: isDark ? "rgba(255, 255, 255, 0.7)" : "rgba(0, 0, 0, 0.6)", 176 | }; 177 | }, [isDark]); 178 | 179 | const renderDetail = useMemo(() => { 180 | return ( 181 | 189 | 190 | 191 | {t("myPage.extractionHistory.field.tableName")}: 192 | 193 | {history.tableName} 194 | 195 | 202 | 203 | {t("myPage.extractionHistory.field.extractedData")}: 204 | 205 | 206 | 207 | {history.columns.map((column) => ( 208 | 209 | {column} 210 | 211 | ))} 212 | 213 | 214 | 215 | 216 | {t("myPage.extractionHistory.field.extractedCondition")}: 217 | 218 | 219 | 220 | {history.conditions.map((condition, idx) => { 221 | return {renderCondition(condition, t)}; 222 | })} 223 | 224 | 225 | 226 | ); 227 | }, [headerStyle, i18n.language]); 228 | 229 | return ( 230 | <> 231 | 232 | setOpenDetail(!openDetail)}> 233 | 237 | 238 | {t("myPage.extractionHistory.field.extractedDatetime")}: 239 | {formattedDatetime} 240 | 241 | 242 | } 243 | /> 244 | {openDetail ? : } 245 | 246 | 247 | 248 | 249 | 254 | 255 | 256 | 257 | ); 258 | }; 259 | 260 | export default ListItemExtractHistory; 261 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListItemMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ListItem, ListItemButton, ListItemText } from "@mui/material"; 2 | import React from "react"; 3 | import { MenuEnum } from "../../../@types/myPage"; 4 | 5 | type Props = { 6 | menu: MenuEnum; 7 | label: string; 8 | selected: MenuEnum; 9 | onClick: (selectMenu: MenuEnum) => void; 10 | }; 11 | 12 | const ListItemMenu: React.FC = ({ selected, label, menu, onClick }) => { 13 | return ( 14 | 15 | { 18 | onClick(menu); 19 | }} 20 | > 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default ListItemMenu; 28 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListMenu.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "@mui/material"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { MenuEnum } from "../../../@types/myPage"; 5 | import ListItemMenu from "./ListItemMenu"; 6 | 7 | type Props = { 8 | selected: MenuEnum; 9 | onClick: (selectMenu: MenuEnum) => void; 10 | }; 11 | 12 | const ListMenu: React.FC = ({ selected, onClick }) => { 13 | const { t } = useTranslation(); 14 | 15 | const menuList: { 16 | menu: MenuEnum; 17 | label: string; 18 | }[] = [ 19 | { 20 | menu: "saved", 21 | label: t("myPage.menu.savedReport"), 22 | }, 23 | { 24 | menu: "shared", 25 | label: t("myPage.menu.sharedReport"), 26 | }, 27 | { 28 | menu: "history", 29 | label: t("myPage.menu.extractionHistory"), 30 | }, 31 | ]; 32 | 33 | return ( 34 | 35 | {menuList.map((menu) => ( 36 | 42 | ))} 43 | 44 | ); 45 | }; 46 | 47 | export default ListMenu; 48 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListMyReport.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import useReportApi from "../../../api/useReportApi"; 4 | import useAlertSnackbar from "../../../hooks/useAlertSnackbar"; 5 | import useLoading from "../../../hooks/useLoading"; 6 | import ListReport from "./ListReport"; 7 | 8 | const ListMyReport = () => { 9 | const { getMyReportList, shareReport } = useReportApi(); 10 | const loading = useLoading(); 11 | const { openSucces } = useAlertSnackbar(); 12 | const { t } = useTranslation(); 13 | 14 | const { data } = getMyReportList(); 15 | 16 | const onShare = useCallback((reportId: string, mail: string) => { 17 | loading.open(); 18 | 19 | // Return Promise for post-processing 20 | return shareReport(reportId, mail) 21 | .then(() => { 22 | openSucces( 23 | t("myPage.reportList.message.shareSuccess", { userName: mail }) 24 | ); 25 | return true; 26 | }) 27 | .catch(() => { 28 | return false; 29 | }) 30 | .finally(() => { 31 | loading.close(); 32 | }); 33 | }, []); 34 | 35 | return ( 36 | 41 | ); 42 | }; 43 | 44 | export default ListMyReport; 45 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListReport.tsx: -------------------------------------------------------------------------------- 1 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 2 | import ShareIcon from "@mui/icons-material/Share"; 3 | import { 4 | Box, 5 | Button, 6 | Divider, 7 | List, 8 | ListItem, 9 | ListItemButton, 10 | ListItemText, 11 | } from "@mui/material"; 12 | import React, { useCallback, useState } from "react"; 13 | import { useTranslation } from "react-i18next"; 14 | import { useNavigate } from "react-router-dom"; 15 | import { Report } from "../../../@types/report"; 16 | import LoadingList from "../../../components/LoadingList"; 17 | import useReportState from "../../report/hooks/useReportState"; 18 | import DialogShareReport from "./DialogShareReport"; 19 | 20 | type Props = { 21 | reports?: Report[]; 22 | emptyMessage?: string; 23 | onShare?: (reportId: string, userId: string) => Promise; 24 | }; 25 | 26 | const ListReport: React.FC = ({ reports, emptyMessage, onShare }) => { 27 | const navigate = useNavigate(); 28 | 29 | const { setReportForCopy } = useReportState(); 30 | const { t } = useTranslation(); 31 | 32 | const [openShareDialog, setOpenShareDialog] = useState(false); 33 | const [reportId, setReportId] = useState(""); 34 | const [reportName, setReportName] = useState(""); 35 | 36 | const gotoExtractionPage = useCallback((reportId: string) => { 37 | navigate(`/report/extract/${reportId}`); 38 | }, []); 39 | 40 | // Go to ConditionPage when copying report 41 | const copyReport = useCallback((report: Report) => { 42 | setReportForCopy(report); 43 | navigate(`/report/conditions/new`); 44 | }, []); 45 | 46 | return ( 47 | <> 48 | { 52 | return onShare 53 | ? onShare(reportId, userId) 54 | : new Promise((resolve) => resolve(false)); 55 | }} 56 | onClose={() => { 57 | setOpenShareDialog(false); 58 | }} 59 | /> 60 | 66 | {!reports ? ( 67 | 68 | ) : reports.length === 0 ? ( 69 | 77 | {emptyMessage} 78 | 79 | ) : ( 80 | <> 81 | 82 | {reports.map((report, idx) => ( 83 | 84 | 85 | { 87 | gotoExtractionPage(report.reportId); 88 | }} 89 | > 90 | 91 | 92 | 93 | 104 | 105 | {onShare ? ( 106 | 119 | ) : null} 120 | 121 | 122 | 123 | ))} 124 | 125 | )} 126 | 127 | 128 | ); 129 | }; 130 | 131 | export default ListReport; 132 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/components/ListSharedReport.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import useReportApi from "../../../api/useReportApi"; 4 | import ListReport from "./ListReport"; 5 | 6 | const ListSharedReport = () => { 7 | const { getSharedReportList } = useReportApi(); 8 | const { t } = useTranslation(); 9 | 10 | const { data } = getSharedReportList(); 11 | return ( 12 | 16 | ); 17 | }; 18 | 19 | export default ListSharedReport; 20 | -------------------------------------------------------------------------------- /frontend/src/features/myPage/pages/MyPage.tsx: -------------------------------------------------------------------------------- 1 | import EditIcon from "@mui/icons-material/Edit"; 2 | import { Button, Card, Divider, Grid } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import React, { useCallback, useEffect, useState } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import { useNavigate } from "react-router-dom"; 7 | import { MenuEnum } from "../../../@types/myPage"; 8 | import useUserApi from "../../../api/useUserApi"; 9 | import useReportState from "../../report/hooks/useReportState"; 10 | import CardProfile from "../components/CardProfile"; 11 | import ListExtractHistory from "../components/ListExtractHistory"; 12 | import ListMenu from "../components/ListMenu"; 13 | import ListMyReport from "../components/ListMyReport"; 14 | import ListSharedReport from "../components/ListSharedReport"; 15 | 16 | const MyPage: React.FC = () => { 17 | const [selectedMenu, setSelectedMenu] = useState("saved"); 18 | 19 | const navigate = useNavigate(); 20 | const { t } = useTranslation(); 21 | 22 | const { getUser } = useUserApi(); 23 | const { data: user } = getUser(); 24 | const { clear } = useReportState(); 25 | 26 | useEffect(() => { 27 | // Initialize ReportState on transition to MyPage 28 | clear(); 29 | }, []); 30 | 31 | const clickCreateReport = useCallback(() => { 32 | navigate({ 33 | pathname: "report/conditions/new", 34 | }); 35 | }, []); 36 | 37 | return ( 38 | 39 | 40 | 41 | 48 | 49 | 56 | 57 | 58 | 59 | 60 | setSelectedMenu(selectMenu)} 63 | /> 64 | 65 | 66 | 67 | {selectedMenu === "history" ? ( 68 | 69 | ) : selectedMenu === "saved" ? ( 70 | 71 | ) : ( 72 | 73 | )} 74 | 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default MyPage; 84 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ButtonExtract.tsx: -------------------------------------------------------------------------------- 1 | import PlayArrowIcon from "@mui/icons-material/PlayArrow"; 2 | import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton"; 3 | import { SxProps, Theme } from "@mui/material"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | type Props = { 8 | sx?: SxProps; 9 | onClick: () => void; 10 | } & LoadingButtonProps; 11 | 12 | const ButtonExtract: React.FC = (props) => { 13 | const { t } = useTranslation(); 14 | return ( 15 | } 21 | > 22 | {t("reportPage.button.extract")} 23 | 24 | ); 25 | }; 26 | 27 | export default ButtonExtract; 28 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/CardArea.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper } from "@mui/material"; 2 | import { grey } from "@mui/material/colors"; 3 | import React, { ReactNode } from "react"; 4 | import useMuiTheme from "../../../hooks/useMuiTheme"; 5 | 6 | type Props = { 7 | height: string; 8 | children: ReactNode; 9 | }; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | const CardArea = React.forwardRef(function CardAreaDnD( 13 | { height, children }, 14 | ref 15 | ) { 16 | const { isDark } = useMuiTheme(); 17 | 18 | return ( 19 | 28 | 35 | {children} 36 | 37 | 38 | ); 39 | }); 40 | 41 | export default CardArea; 42 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/CardAreaDnD.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import CardArea from "./CardArea"; 3 | 4 | type Props = { 5 | height: string; 6 | children: ReactNode; 7 | }; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | const CardAreaDnD = React.forwardRef(function CardAreaDnD( 11 | props, 12 | ref 13 | ) { 14 | return ; 15 | }); 16 | 17 | export default CardAreaDnD; 18 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/CardExtractConditionDnD.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from "@mui/material"; 2 | import React, { memo, useCallback } from "react"; 3 | import { useDrop } from "react-dnd"; 4 | import { ExtractCondition, TableColumn } from "../../../@types/report"; 5 | import CardAreaDnD from "./CardAreaDnD"; 6 | import ItemExtractConditionDnD from "./ItemExtractConditionDnD"; 7 | 8 | export interface Props { 9 | droppedConditions: ExtractCondition[]; 10 | onDrop: (item: TableColumn) => void; 11 | moveItem: (dragIndex: number, hoverIndex: number) => void; 12 | onChange: (value: ExtractCondition, index: number) => void; 13 | onDelete: (index: number) => void; 14 | } 15 | 16 | const CardExtractConditionDnD: React.FC = ({ 17 | onDrop, 18 | droppedConditions, 19 | moveItem, 20 | onChange, 21 | onDelete, 22 | }) => { 23 | // Settings for Drag & Drop 24 | const [, drop] = useDrop({ 25 | accept: "item", 26 | drop: onDrop, 27 | collect: (monitor) => ({ 28 | isOver: monitor.isOver(), 29 | canDrop: monitor.canDrop(), 30 | }), 31 | }); 32 | 33 | const renderItem = useCallback( 34 | (item: ExtractCondition, index: number) => { 35 | return ( 36 | 42 | { 48 | onChange(value, index); 49 | }} 50 | onDelete={(index) => { 51 | onDelete(index); 52 | }} 53 | /> 54 | 55 | ); 56 | }, 57 | [moveItem, onChange, onDelete] 58 | ); 59 | 60 | return ( 61 | 62 | 63 | {droppedConditions.map((condition, i) => renderItem(condition, i))} 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default memo(CardExtractConditionDnD); 70 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/CardSelectColumnDnD.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from "@mui/material"; 2 | import React, { memo, useCallback } from "react"; 3 | import { useDrop } from "react-dnd"; 4 | import { TableColumn } from "../../../@types/report"; 5 | import CardAreaDnD from "./CardAreaDnD"; 6 | import ItemSortableColumnDnD from "./ItemSortableColumnDnD"; 7 | 8 | export interface Props { 9 | selectedColumns: TableColumn[]; 10 | onDrop: (item: TableColumn) => void; 11 | moveItem: (dragIndex: number, hoverIndex: number) => void; 12 | onDelete: (index: number) => void; 13 | } 14 | 15 | const CardSelectColumnDnD: React.FC = ({ 16 | onDrop, 17 | selectedColumns, 18 | moveItem, 19 | onDelete, 20 | }) => { 21 | const [, drop] = useDrop({ 22 | accept: "item", 23 | drop: onDrop, 24 | collect: (monitor) => ({ 25 | isOver: monitor.isOver(), 26 | canDrop: monitor.canDrop(), 27 | }), 28 | }); 29 | 30 | const renderColumn = useCallback( 31 | (col: TableColumn, index: number) => { 32 | return ( 33 | 34 | 41 | 42 | ); 43 | }, 44 | [moveItem, onDelete] 45 | ); 46 | 47 | return ( 48 | 49 | 50 | {selectedColumns.map((col, i) => renderColumn(col, i))} 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default memo(CardSelectColumnDnD); 57 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ContainerExtractSetting.tsx: -------------------------------------------------------------------------------- 1 | import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; 2 | import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; 3 | import { Button, Grid, Paper, Typography } from "@mui/material"; 4 | import { Box } from "@mui/system"; 5 | import React, { useCallback, useEffect, useState } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | import { TableColumn } from "../../../@types/report"; 8 | import Loading from "../../../components/Loading"; 9 | import useReportState from "../hooks/useReportState"; 10 | import CardAreaDnD from "./CardAreaDnD"; 11 | import CardExtractConditionDnD from "./CardExtractConditionDnD"; 12 | import CardSelectColumnDnD from "./CardSelectColumnDnD"; 13 | import ItemColumn from "./ItemColumn"; 14 | 15 | type Props = { 16 | columns: TableColumn[]; 17 | loading: boolean; 18 | }; 19 | 20 | // ASC:1 DESC:-1 Not Sort:0 21 | type Direction = 1 | 0 | -1; 22 | 23 | const ContainerExtractSetting: React.FC = ({ columns, loading }) => { 24 | const { 25 | selectColumns, 26 | extractConditions, 27 | moveSelectColumn, 28 | moveExtractCondition, 29 | addSelectColumn, 30 | addExtractCondition, 31 | updateExtractCondition, 32 | deleteSelectColumn, 33 | deleteExtractCondition, 34 | } = useReportState(); 35 | 36 | const { t } = useTranslation(); 37 | 38 | const [sortableColumns, serSortableColumns] = useState(columns); 39 | useEffect(() => { 40 | // When columns is initialized, the sort order is initialized 41 | if (columns.length === 0) { 42 | setDirection(0); 43 | } 44 | serSortableColumns(columns); 45 | }, [columns]); 46 | 47 | const [direction, setDirection] = useState(0); 48 | 49 | /** 50 | * Sort columns 51 | * @param direction 52 | */ 53 | const sortColumns = useCallback( 54 | (direction: Direction) => { 55 | if (columns.length === 0) { 56 | return; 57 | } 58 | const tmp: TableColumn[] = []; 59 | serSortableColumns( 60 | tmp 61 | .concat(columns) 62 | .sort((a, b) => (a.name > b.name ? 1 : -1) * direction) 63 | ); 64 | }, 65 | [columns] 66 | ); 67 | 68 | const onClickSort = useCallback(() => { 69 | // Not sort -> ASC -> DESC -> Not Sort ... 70 | let tmp: Direction = 0; 71 | if (direction === 0) { 72 | tmp = 1; 73 | } else if (direction === 1) { 74 | tmp = -1; 75 | } 76 | sortColumns(tmp); 77 | setDirection(tmp); 78 | }, [direction]); 79 | 80 | return ( 81 | 82 | 83 | 84 | 85 | 86 | {t("reportPage.field.columns")} 87 | 88 | 104 | 105 | 106 | {/* Loading animation is desployed when loading */} 107 | {loading ? : null} 108 | {sortableColumns.map((col, index) => ( 109 | 110 | ))} 111 | 112 | 113 | 114 | 115 | 116 | 117 | {t("reportPage.field.selectColumns")} 118 | 119 | addSelectColumn(item)} 121 | selectedColumns={selectColumns} 122 | moveItem={moveSelectColumn} 123 | onDelete={deleteSelectColumn} 124 | /> 125 | 126 | 127 | 128 | 129 | {t("reportPage.field.extractConditions")} 130 | 131 | addExtractCondition(item)} 133 | droppedConditions={extractConditions} 134 | moveItem={moveExtractCondition} 135 | onChange={updateExtractCondition} 136 | onDelete={deleteExtractCondition} 137 | /> 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | export default ContainerExtractSetting; 145 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/DialogRegisterReport.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogContentText, 8 | DialogTitle, 9 | TextField, 10 | } from "@mui/material"; 11 | import React, { useState } from "react"; 12 | import { useTranslation } from "react-i18next"; 13 | 14 | type Props = { 15 | open: boolean; 16 | onRegister: (reportName: string) => void; 17 | onClose: () => void; 18 | }; 19 | 20 | const DialogRegisterReport: React.FC = ({ 21 | open, 22 | onRegister, 23 | onClose, 24 | }) => { 25 | const { t } = useTranslation(); 26 | const [reportName, setReportName] = useState(""); 27 | 28 | return ( 29 | 30 | {t("reportPage.registerReportDialog.title")} 31 | 32 | 33 | 34 | {t("reportPage.registerReportDialog.description")} 35 | 36 | { 41 | setReportName(e.target.value); 42 | }} 43 | /> 44 | 45 | 46 | 47 | 50 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default DialogRegisterReport; 65 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/DisplayColumn.tsx: -------------------------------------------------------------------------------- 1 | import AbcIcon from "@mui/icons-material/Abc"; 2 | import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; 3 | import NumbersIcon from "@mui/icons-material/Numbers"; 4 | import { Grid } from "@mui/material"; 5 | import { Box } from "@mui/system"; 6 | import React, { useCallback } from "react"; 7 | import { TableColumn } from "../../../@types/report"; 8 | 9 | export type Props = { 10 | col: TableColumn; 11 | }; 12 | 13 | const DisplayColumn: React.FC = ({ col }) => { 14 | const renderIcon = useCallback((type: TableColumn["type"]) => { 15 | return type === "string" ? ( 16 | 17 | ) : type === "number" ? ( 18 | 19 | ) : ( 20 | 21 | ); 22 | }, []); 23 | return ( 24 | 25 | {renderIcon(col.type)} 26 | {col.name} 27 | 28 | ); 29 | }; 30 | 31 | export default DisplayColumn; 32 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/InputConditionDate.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Grid, 3 | TextField, 4 | ToggleButton, 5 | ToggleButtonGroup, 6 | Typography, 7 | } from "@mui/material"; 8 | import React, { useCallback, useMemo } from "react"; 9 | import { useTranslation } from "react-i18next"; 10 | import { 11 | ConditionType, 12 | ExtractCondition, 13 | RelativeDateNPeriod, 14 | RelativeDatePeriod, 15 | } from "../../../@types/report"; 16 | import DatePicker from "../../../components/DatePicker"; 17 | 18 | type Props = { 19 | value: ExtractCondition; 20 | onChange: (value: ExtractCondition) => void; 21 | }; 22 | 23 | /** 24 | * Component for date type search condition 25 | * @param param0 26 | * @returns 27 | */ 28 | const InputConditionDate: React.FC = ({ value, onChange }) => { 29 | const { t, i18n } = useTranslation(); 30 | 31 | const SearchTypeOption = useMemo<{ label: string; value: ConditionType }[]>( 32 | () => [ 33 | { 34 | label: t("reportPage.dateConditions.relative"), 35 | value: "relativeDate", 36 | }, 37 | { 38 | label: t("reportPage.dateConditions.relativeN"), 39 | value: "relativeDateN", 40 | }, 41 | { 42 | label: t("reportPage.dateConditions.absolute"), 43 | value: "absoluteDate", 44 | }, 45 | ], 46 | [i18n.language] 47 | ); 48 | 49 | const RelativeTypeOption = useMemo< 50 | { label: string; value: RelativeDatePeriod }[] 51 | >( 52 | () => [ 53 | { 54 | label: t("reportPage.dateConditions.today"), 55 | value: "today", 56 | }, 57 | { 58 | label: t("reportPage.dateConditions.yesterday"), 59 | value: "yesterday", 60 | }, 61 | { 62 | label: t("reportPage.dateConditions.lastWeek"), 63 | value: "lastWeek", 64 | }, 65 | { 66 | label: t("reportPage.dateConditions.lastMonth"), 67 | value: "lastMonth", 68 | }, 69 | { 70 | label: t("reportPage.dateConditions.lastYear"), 71 | value: "lastYear", 72 | }, 73 | ], 74 | [i18n.language] 75 | ); 76 | 77 | const RelativeNTypeOption = useMemo< 78 | { 79 | label: string; 80 | value: RelativeDateNPeriod; 81 | }[] 82 | >( 83 | () => [ 84 | { 85 | label: t("reportPage.dateConditions.dayBefore"), 86 | value: "day", 87 | }, 88 | { 89 | label: t("reportPage.dateConditions.weekBefore"), 90 | value: "week", 91 | }, 92 | { 93 | label: t("reportPage.dateConditions.monthBofore"), 94 | value: "month", 95 | }, 96 | { 97 | label: t("reportPage.dateConditions.yearBefore"), 98 | value: "year", 99 | }, 100 | ], 101 | [i18n.language] 102 | ); 103 | 104 | // render ToggleButton options for each search type 105 | const renderOption = useCallback( 106 | (options: { label: string; value: string }[]) => { 107 | return options.map((option) => ( 108 | 109 | {option.label} 110 | 111 | )); 112 | }, 113 | [] 114 | ); 115 | 116 | const renderSearchType = useCallback(() => { 117 | return renderOption(SearchTypeOption); 118 | }, [i18n.language]); 119 | 120 | const renderRelativeType = useCallback(() => { 121 | return renderOption(RelativeTypeOption); 122 | }, [i18n.language]); 123 | 124 | const renderRelativeNType = useCallback(() => { 125 | return renderOption(RelativeNTypeOption); 126 | }, [i18n.language]); 127 | 128 | const onChangeSearchType = useCallback( 129 | (type: ConditionType) => { 130 | if (type) { 131 | if (type === "relativeDate") { 132 | onChange({ 133 | columnName: value.columnName, 134 | type: "relativeDate", 135 | period: "today", 136 | }); 137 | } else if (type === "relativeDateN") { 138 | onChange({ 139 | columnName: value.columnName, 140 | type: "relativeDateN", 141 | period: "day", 142 | n: 1, 143 | }); 144 | } else if (type === "absoluteDate") { 145 | onChange({ 146 | columnName: value.columnName, 147 | type: "absoluteDate", 148 | startDate: "", 149 | endDate: "", 150 | }); 151 | } 152 | } 153 | }, 154 | [value.columnName] 155 | ); 156 | 157 | const onChangeRelative = useCallback( 158 | (period: RelativeDatePeriod) => { 159 | onChange({ 160 | columnName: value.columnName, 161 | type: "relativeDate", 162 | period, 163 | }); 164 | }, 165 | [value.columnName] 166 | ); 167 | 168 | const onChangeRelativeN = useCallback( 169 | (n: number, period: RelativeDateNPeriod) => { 170 | onChange({ 171 | columnName: value.columnName, 172 | type: "relativeDateN", 173 | period, 174 | n, 175 | }); 176 | }, 177 | [value.columnName] 178 | ); 179 | 180 | const onChangeAbsolute = useCallback( 181 | (startDate: string, endDate: string) => { 182 | onChange({ 183 | columnName: value.columnName, 184 | type: "absoluteDate", 185 | startDate, 186 | endDate, 187 | }); 188 | }, 189 | [value.columnName] 190 | ); 191 | 192 | return ( 193 | <> 194 | 195 | 196 | onChangeSearchType(value)} 202 | > 203 | {renderSearchType()} 204 | 205 | 206 | {value.type === "relativeDate" ? ( 207 | 208 | { 214 | onChangeRelative(period); 215 | }} 216 | > 217 | {renderRelativeType()} 218 | 219 | 220 | ) : value.type === "relativeDateN" ? ( 221 | <> 222 | 223 | { 230 | onChangeRelativeN( 231 | Number.parseInt(e.target.value), 232 | value.period 233 | ); 234 | }} 235 | /> 236 | 237 | { 243 | onChangeRelativeN(value.n, period); 244 | }} 245 | > 246 | {renderRelativeNType()} 247 | 248 | 249 | 250 | ) : value.type === "absoluteDate" ? ( 251 | 252 | 253 | { 256 | onChangeAbsolute(startDate, value.endDate); 257 | }} 258 | /> 259 | 260 | 261 | 262 | 263 | 264 | { 267 | onChangeAbsolute(value.startDate, endDate); 268 | }} 269 | /> 270 | 271 | 272 | ) : null} 273 | 274 | 275 | ); 276 | }; 277 | 278 | export default InputConditionDate; 279 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ItemColumn.tsx: -------------------------------------------------------------------------------- 1 | import { Paper } from "@mui/material"; 2 | import React, { memo } from "react"; 3 | import { useDrag } from "react-dnd"; 4 | import { TableColumn } from "../../../@types/report"; 5 | import DisplayColumn from "./DisplayColumn"; 6 | 7 | export type Props = { 8 | col: TableColumn; 9 | }; 10 | 11 | const ItemColumn: React.FC = ({ col }) => { 12 | const [{ opacity }, drag] = useDrag( 13 | () => ({ 14 | type: "item", 15 | item: col, 16 | collect: (monitor) => ({ 17 | opacity: monitor.isDragging() ? 0.4 : 1, 18 | }), 19 | }), 20 | [col] 21 | ); 22 | 23 | return ( 24 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default memo(ItemColumn); 39 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ItemExtractCondition.tsx: -------------------------------------------------------------------------------- 1 | import DeleteIcon from "@mui/icons-material/Delete"; 2 | import { 3 | Grid, 4 | IconButton, 5 | Paper, 6 | SxProps, 7 | TextField, 8 | Theme, 9 | } from "@mui/material"; 10 | import type { Identifier } from "dnd-core"; 11 | import React, { memo, useCallback } from "react"; 12 | import { 13 | ConditionOperator, 14 | ExtractCondition, 15 | TableColumn, 16 | } from "../../../@types/report"; 17 | import DisplayColumn from "./DisplayColumn"; 18 | import InputConditionDate from "./InputConditionDate"; 19 | 20 | import { useTranslation } from "react-i18next"; 21 | import SelectExtractCondition from "./SelectExtractCondition"; 22 | 23 | type Props = { 24 | condition: ExtractCondition; 25 | sx?: SxProps; 26 | handlerId?: Identifier | null; 27 | onChange: (value: ExtractCondition) => void; 28 | } & ( 29 | | { 30 | disableDelete: true; 31 | } 32 | | { 33 | disableDelete?: false; 34 | index: number; 35 | onDelete: (index: number) => void; 36 | } 37 | ); 38 | 39 | const getColumnType = ( 40 | conditionType: ExtractCondition["type"] 41 | ): TableColumn["type"] => { 42 | switch (conditionType) { 43 | case "string": 44 | return "string"; 45 | case "number": 46 | return "number"; 47 | case "absoluteDate": 48 | return "date"; 49 | case "relativeDate": 50 | return "date"; 51 | case "relativeDateN": 52 | return "date"; 53 | case "time": 54 | return "date"; 55 | } 56 | }; 57 | 58 | /** 59 | * Conditions for String, Number 60 | */ 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | const ItemExtractCondition = React.forwardRef( 63 | function ItemExtractConditionconst(props, ref) { 64 | const { condition, disableDelete, onChange, handlerId, sx } = props; 65 | const { t } = useTranslation(); 66 | 67 | const onChangeOperator = useCallback( 68 | (value: ConditionOperator) => { 69 | if (condition.type === "string") { 70 | onChange({ 71 | ...condition, 72 | operator: value, 73 | }); 74 | } else if (condition.type === "number") { 75 | onChange({ 76 | ...condition, 77 | operator: value, 78 | }); 79 | } 80 | }, 81 | [condition, onChange] 82 | ); 83 | 84 | const onChangeValue = useCallback( 85 | (value: string | number) => { 86 | if (condition.type === "string") { 87 | onChange({ 88 | ...condition, 89 | value: value as string, 90 | }); 91 | } else if (condition.type === "number") { 92 | onChange({ 93 | ...condition, 94 | value: value as number, 95 | }); 96 | } 97 | }, 98 | [condition, onChange] 99 | ); 100 | 101 | return ( 102 | 110 | 111 | 112 | 118 | 124 | 125 | 126 | 127 | {condition.type === "absoluteDate" || 128 | condition.type === "relativeDate" || 129 | condition.type === "relativeDateN" || 130 | condition.type === "time" ? ( 131 | 132 | 133 | 134 | ) : ( 135 | <> 136 | 137 | 142 | 143 | 144 | { 150 | onChangeValue(e.target.value); 151 | }} 152 | /> 153 | 154 | 155 | )} 156 | 157 | {!disableDelete ? ( 158 | 159 | { 162 | props.onDelete(props.index); 163 | }} 164 | > 165 | 166 | 167 | 168 | ) : null} 169 | 170 | 171 | ); 172 | } 173 | ); 174 | 175 | export default memo(ItemExtractCondition); 176 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ItemExtractConditionDnD.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo } from "react"; 2 | import { ExtractCondition } from "../../../@types/report"; 3 | import useSortableDnD from "../../../hooks/useSortableDnD"; 4 | import ItemExtractCondition from "./ItemExtractCondition"; 5 | 6 | type Props = { 7 | id: string; 8 | index: number; 9 | condition: ExtractCondition; 10 | moveItem: (dragIndex: number, hoverIndex: number) => void; 11 | onChange: (value: ExtractCondition) => void; 12 | onDelete: (index: number) => void; 13 | }; 14 | 15 | const ItemExtractConditionDnD: React.FC = (props) => { 16 | // Settings Drag & Drop 17 | const { handlerId, isDragging, ref } = useSortableDnD({ 18 | id: props.id, 19 | index: props.index, 20 | moveItem: props.moveItem, 21 | type: "ConditionItem", 22 | }); 23 | const opacity = useMemo(() => (isDragging ? 0 : 1), [isDragging]); 24 | 25 | return ( 26 | 35 | ); 36 | }; 37 | 38 | export default memo(ItemExtractConditionDnD); 39 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/ItemSortableColumnDnD.tsx: -------------------------------------------------------------------------------- 1 | import DeleteIcon from "@mui/icons-material/Delete"; 2 | import { Grid, IconButton, Paper } from "@mui/material"; 3 | import React, { memo, useMemo } from "react"; 4 | import { TableColumn } from "../../../@types/report"; 5 | import useSortableDnD from "../../../hooks/useSortableDnD"; 6 | import DisplayColumn from "./DisplayColumn"; 7 | 8 | export interface Props { 9 | id: string; 10 | index: number; 11 | col: TableColumn; 12 | moveItem: (dragIndex: number, hoverIndex: number) => void; 13 | onDelete: (index: number) => void; 14 | } 15 | 16 | const ItemSortableColumnDnD: React.FC = ({ 17 | id, 18 | col, 19 | index, 20 | moveItem, 21 | onDelete, 22 | }) => { 23 | // Settings Drag & Drop 24 | const { handlerId, isDragging, ref } = useSortableDnD({ 25 | id, 26 | index, 27 | moveItem, 28 | type: "SelectItem", 29 | }); 30 | const opacity = useMemo(() => (isDragging ? 0 : 1), [isDragging]); 31 | 32 | return ( 33 | 43 | 44 | 45 | 46 | 47 | 48 | { 52 | onDelete(index); 53 | }} 54 | > 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default memo(ItemSortableColumnDnD); 64 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/SelectExtractCondition.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from "@mui/material"; 2 | import React, { useMemo } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { ConditionOperator } from "../../../@types/report"; 5 | 6 | type Props = { 7 | type: "string" | "number"; 8 | operator: ConditionOperator; 9 | onChange: (value: ConditionOperator) => void; 10 | }; 11 | 12 | type Option = { 13 | label: string; 14 | value: ConditionOperator; 15 | }; 16 | 17 | const SelectExtractCondition: React.FC = ({ 18 | type, 19 | operator, 20 | onChange, 21 | }) => { 22 | const { t } = useTranslation(); 23 | 24 | const options: Option[] = useMemo(() => { 25 | if (type === "string") { 26 | return [ 27 | { 28 | label: t("app.stringOperator.eq"), 29 | value: "eq", 30 | }, 31 | { 32 | label: t("app.stringOperator.neq"), 33 | value: "neq", 34 | }, 35 | { 36 | label: t("app.stringOperator.contains"), 37 | value: "contains", 38 | }, 39 | ]; 40 | } else if (type === "number") { 41 | return [ 42 | { 43 | label: t("app.numberOperator.eq"), 44 | value: "eq", 45 | }, 46 | { 47 | label: t("app.numberOperator.neq"), 48 | value: "neq", 49 | }, 50 | { 51 | label: t("app.numberOperator.gt"), 52 | value: "gt", 53 | }, 54 | { 55 | label: t("app.numberOperator.gte"), 56 | value: "gte", 57 | }, 58 | { 59 | label: t("app.numberOperator.lt"), 60 | value: "lt", 61 | }, 62 | { 63 | label: t("app.numberOperator.lte"), 64 | value: "lte", 65 | }, 66 | ]; 67 | } else { 68 | return []; 69 | } 70 | }, [type]); 71 | 72 | const selectedOption: Option = useMemo(() => { 73 | return options.filter((opt) => opt.value === operator)[0]; 74 | }, [options, operator]); 75 | 76 | return ( 77 | <> 78 | { 83 | return option.value === v.value; 84 | }} 85 | disableClearable 86 | onChange={(e, opt) => { 87 | if (opt) { 88 | onChange(opt.value); 89 | } 90 | }} 91 | renderInput={(params) => ( 92 | 97 | )} 98 | /> 99 | 100 | ); 101 | }; 102 | 103 | export default SelectExtractCondition; 104 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/SelectTable.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from "@mui/material"; 2 | import React, { useEffect, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import useTableApi from "../../../api/useTableApi"; 5 | 6 | type Props = { 7 | tableName: string; 8 | onChange: (value: string) => void; 9 | }; 10 | 11 | const SelectTable: React.FC = ({ tableName, onChange }) => { 12 | const { getTableList } = useTableApi(); 13 | const { t } = useTranslation(); 14 | 15 | const { data } = getTableList(); 16 | 17 | const [options, setOptions] = useState([]); 18 | useEffect(() => { 19 | setOptions(data?.tables ?? []); 20 | }, [data]); 21 | 22 | return ( 23 | ( 30 | 34 | )} 35 | onChange={(_, value) => { 36 | onChange(value ?? ""); 37 | }} 38 | /> 39 | ); 40 | }; 41 | 42 | export default SelectTable; 43 | -------------------------------------------------------------------------------- /frontend/src/features/report/components/TableReport.tsx: -------------------------------------------------------------------------------- 1 | import InfoIcon from "@mui/icons-material/Info"; 2 | import { Box, Typography } from "@mui/material"; 3 | import MaterialReactTable, { MRT_ColumnDef } from "material-react-table"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import useLocale from "../../../hooks/useLocale"; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 9 | type Props = {}> = { 10 | data: TData[]; 11 | columns: MRT_ColumnDef[]; 12 | loading?: boolean; 13 | }; 14 | 15 | const TableReport: React.FC = ({ data, columns, loading }) => { 16 | const { t } = useTranslation(); 17 | const { mrtLocale } = useLocale(); 18 | return ( 19 | ( 29 | 30 | 31 | 32 | {t("reportPage.message.selectMultipleColumnsNotice")} 33 | 34 | 35 | )} 36 | /> 37 | ); 38 | }; 39 | 40 | export default TableReport; 41 | -------------------------------------------------------------------------------- /frontend/src/features/report/hooks/useReportState.ts: -------------------------------------------------------------------------------- 1 | import update from "immutability-helper"; 2 | import { useTranslation } from "react-i18next"; 3 | import { atom, useRecoilState } from "recoil"; 4 | import { recoilPersist } from "recoil-persist"; 5 | import { ExtractCondition, Report, TableColumn } from "../../../@types/report"; 6 | 7 | // SessionStorageに格納して、更新ボタンでStateが初期化されないようにする 8 | const { persistAtom } = recoilPersist({ 9 | key: "recoil-persist", 10 | storage: sessionStorage, 11 | }); 12 | 13 | /** 14 | * Defines Report State 15 | */ 16 | const reportIdState = atom({ 17 | key: "reportId", 18 | default: "", 19 | effects_UNSTABLE: [persistAtom], 20 | }); 21 | 22 | const reportNameState = atom({ 23 | key: "reportName", 24 | default: "", 25 | effects_UNSTABLE: [persistAtom], 26 | }); 27 | 28 | const tableNameState = atom({ 29 | key: "tableId", 30 | default: "", 31 | effects_UNSTABLE: [persistAtom], 32 | }); 33 | 34 | const selectColumnsState = atom({ 35 | key: "selectColumns", 36 | default: [], 37 | effects_UNSTABLE: [persistAtom], 38 | }); 39 | 40 | const extractConditionsState = atom({ 41 | key: "extractConditions", 42 | default: [], 43 | effects_UNSTABLE: [persistAtom], 44 | }); 45 | 46 | // Generate initial search conditions for each type 47 | const generateInitialConditions = ( 48 | columnName: string, 49 | type: TableColumn["type"] 50 | ): ExtractCondition => { 51 | switch (type) { 52 | case "string": 53 | return { 54 | type, 55 | columnName, 56 | operator: "eq", 57 | value: null, 58 | }; 59 | case "number": 60 | return { 61 | type, 62 | columnName, 63 | operator: "eq", 64 | value: null, 65 | }; 66 | case "date": 67 | return { 68 | type: "relativeDate", 69 | columnName, 70 | period: "today", 71 | }; 72 | } 73 | }; 74 | 75 | /** 76 | * Hooks for manage ReportState 77 | * @returns 78 | */ 79 | const useReportState = () => { 80 | const { t } = useTranslation(); 81 | 82 | const [reportId, setReportId] = useRecoilState(reportIdState); 83 | const [reportName, setReportName] = useRecoilState(reportNameState); 84 | const [tableName, setTableName] = useRecoilState(tableNameState); 85 | const [selectColumns, setSelectColumns] = useRecoilState(selectColumnsState); 86 | const [extractConditions, setExtractConditions] = useRecoilState( 87 | extractConditionsState 88 | ); 89 | 90 | return { 91 | reportId, 92 | reportName, 93 | setReportName, 94 | tableName, 95 | setTableName, 96 | selectColumns, 97 | extractConditions, 98 | 99 | /** 100 | * initialize all state 101 | */ 102 | clear: () => { 103 | setReportId(""); 104 | setReportName(""); 105 | setTableName(""); 106 | setSelectColumns([]); 107 | setExtractConditions([]); 108 | }, 109 | 110 | /** 111 | * Set all States related to the Report 112 | * @param report 113 | */ 114 | setReport: (report: Report) => { 115 | setReportId(report.reportId); 116 | setReportName(report.reportName); 117 | setTableName(report.tableName); 118 | // typeは一律でstringを設定し、画面から最新化する 119 | setSelectColumns( 120 | report.columns.map((col) => ({ 121 | name: col, 122 | type: "string", 123 | })) 124 | ); 125 | setExtractConditions(report.conditions); 126 | }, 127 | 128 | /** 129 | * Set all States related to the Report(for copy) 130 | * @param report 131 | */ 132 | setReportForCopy: (report: Report) => { 133 | setReportId(""); 134 | setReportName( 135 | t("reportPage.field.prefixCopiedReport") + report.reportName 136 | ); 137 | setTableName(report.tableName); 138 | // typeは一律でstringを設定し、画面から最新化する 139 | setSelectColumns( 140 | report.columns.map((col) => ({ 141 | name: col, 142 | type: "string", 143 | })) 144 | ); 145 | setExtractConditions(report.conditions); 146 | }, 147 | 148 | /** 149 | * Update SelectColumns State by TableColumns 150 | * @param tableColumns 151 | */ 152 | updateSelectColumnType: (tableColumns: TableColumn[]) => { 153 | const tmp: TableColumn[] = []; 154 | selectColumns.forEach((col) => { 155 | const find = tableColumns.find( 156 | (tableCol) => tableCol.name === col.name 157 | ); 158 | if (find) { 159 | tmp.push(find); 160 | } 161 | }); 162 | setSelectColumns(tmp); 163 | }, 164 | /** 165 | * Move SelectColumns state 166 | * @param fromIndex 167 | * @param toIndex 168 | */ 169 | moveSelectColumn: (fromIndex: number, toIndex: number) => { 170 | setSelectColumns((prevCards) => 171 | update(prevCards, { 172 | $splice: [ 173 | [fromIndex, 1], 174 | [toIndex, 0, prevCards[fromIndex]], 175 | ], 176 | }) 177 | ); 178 | }, 179 | /** 180 | * Move ExtractConditions state 181 | * @param fromIndex 182 | * @param toIndex 183 | */ 184 | moveExtractCondition: (fromIndex: number, toIndex: number) => { 185 | setExtractConditions((prevCards) => 186 | update(prevCards, { 187 | $splice: [ 188 | [fromIndex, 1], 189 | [toIndex, 0, prevCards[fromIndex]], 190 | ], 191 | }) 192 | ); 193 | }, 194 | /** 195 | * Add to the end of SelectColumns state 196 | * @param column 197 | * @returns 198 | */ 199 | addSelectColumn: (column: TableColumn) => { 200 | // 既に追加されている項目は追加しない 201 | if (selectColumns.findIndex((col) => col.name === column.name) > -1) { 202 | return; 203 | } 204 | 205 | setSelectColumns( 206 | update(selectColumns, column ? { $push: [column] } : { $push: [] }) 207 | ); 208 | }, 209 | 210 | /** 211 | * Add generated conditions by table columns to the end of ExtractConditions state 212 | * @param item 213 | */ 214 | addExtractCondition: (item: TableColumn) => { 215 | setExtractConditions( 216 | update( 217 | extractConditions, 218 | item 219 | ? { 220 | $push: [generateInitialConditions(item.name, item.type)], 221 | } 222 | : { $push: [] } 223 | ) 224 | ); 225 | }, 226 | 227 | /** 228 | * Update ExtractConditions State of the specified index 229 | * @param value new ExtractCondition 230 | * @param index update target index 231 | */ 232 | updateExtractCondition: (value: ExtractCondition, index: number) => { 233 | // Replace elements with splice to make it work reactively 234 | setExtractConditions( 235 | update(extractConditions, { 236 | $splice: [[index, 1, value]], 237 | }) 238 | ); 239 | }, 240 | 241 | /** 242 | * Delete SelectColumns State by index 243 | * @param index delete target index 244 | */ 245 | deleteSelectColumn: (index: number) => { 246 | setSelectColumns( 247 | update(selectColumns, { 248 | $splice: [[index, 1]], 249 | }) 250 | ); 251 | }, 252 | 253 | /** 254 | * Delete ExtractConditions State by index 255 | * @param index delete target index 256 | */ 257 | deleteExtractCondition: (index: number) => { 258 | setExtractConditions( 259 | update(extractConditions, { 260 | $splice: [[index, 1]], 261 | }) 262 | ); 263 | }, 264 | }; 265 | }; 266 | 267 | export default useReportState; 268 | -------------------------------------------------------------------------------- /frontend/src/features/report/hooks/useRestoreReportState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react"; 2 | import useReportApi from "../../../api/useReportApi"; 3 | import useReportState from "./useReportState"; 4 | 5 | const useRestoreReportState = (targetReportId: string) => { 6 | const { reportId, setReport } = useReportState(); 7 | const { getReportById } = useReportApi(); 8 | 9 | // should retrieve and restore reports from the back end 10 | const shouldRestoreReport = useMemo(() => { 11 | // Not retrieved if NewReport 12 | // Not retrieved if report information is set in State (report is being edited). 13 | return targetReportId !== "new" && targetReportId !== reportId; 14 | }, []); 15 | 16 | // Get report if ReportId specified 17 | const { data: resGetReport } = getReportById( 18 | shouldRestoreReport ? targetReportId : undefined 19 | ); 20 | 21 | useEffect(() => { 22 | // Set Report State to the report retrieved from the backend 23 | if (resGetReport) { 24 | setReport(resGetReport.report); 25 | } 26 | }, [resGetReport]); 27 | 28 | return { 29 | shouldRestoreReport, 30 | }; 31 | }; 32 | 33 | export default useRestoreReportState; 34 | -------------------------------------------------------------------------------- /frontend/src/features/report/pages/ReportConditionsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, Grid, Typography } from "@mui/material"; 2 | import React, { useCallback, useEffect } from "react"; 3 | import { DndProvider } from "react-dnd"; 4 | import { HTML5Backend } from "react-dnd-html5-backend"; 5 | import { useTranslation } from "react-i18next"; 6 | import { useNavigate, useParams } from "react-router-dom"; 7 | import useTableApi from "../../../api/useTableApi"; 8 | import useAlertSnackbar from "../../../hooks/useAlertSnackbar"; 9 | import ButtonExtract from "../components/ButtonExtract"; 10 | import ContainerExtractSetting from "../components/ContainerExtractSetting"; 11 | import SelectTable from "../components/SelectTable"; 12 | import useReportState from "../hooks/useReportState"; 13 | import useRestoreReportState from "../hooks/useRestoreReportState"; 14 | 15 | const ReportConditionsPage: React.FC = () => { 16 | const params = useParams(); 17 | const navigate = useNavigate(); 18 | 19 | const { 20 | tableName, 21 | setTableName, 22 | clear, 23 | selectColumns, 24 | updateSelectColumnType, 25 | extractConditions, 26 | } = useReportState(); 27 | 28 | const { getTableColumns } = useTableApi(); 29 | const { data: resColumns } = getTableColumns(tableName); 30 | const alert = useAlertSnackbar(); 31 | 32 | const { t } = useTranslation(); 33 | 34 | useRestoreReportState(params.reportId ?? ""); 35 | 36 | useEffect(() => { 37 | // Update type of SelectColmunState when a report exists. 38 | // NOTE: setReport and setReportForCopy in useReportState set string type to all columns 39 | if (resColumns?.columns) { 40 | updateSelectColumnType(resColumns.columns); 41 | } 42 | }, [resColumns]); 43 | 44 | const onClickExtract = useCallback(() => { 45 | if (selectColumns.length === 0) { 46 | alert.openError(t("reportPage.error.requiredColumns")); 47 | } else if (extractConditions.length === 0) { 48 | alert.openError(t("reportPage.error.requiredConditions")); 49 | } else { 50 | navigate({ 51 | pathname: `/report/extract/${params.reportId}`, 52 | }); 53 | return; 54 | } 55 | }, [selectColumns, extractConditions]); 56 | 57 | const onChangeTableId = useCallback( 58 | (newTableName: string) => { 59 | // Clear state when tableName changed 60 | if (tableName !== newTableName) { 61 | clear(); 62 | } 63 | setTableName(newTableName); 64 | }, 65 | [tableName] 66 | ); 67 | 68 | return ( 69 | 70 | 71 | 72 | 79 | 80 | 81 | {t("reportPage.field.targetTable")} 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | ); 101 | }; 102 | 103 | export default ReportConditionsPage; 104 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAlertSnackbar.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from "recoil"; 2 | import AlertSnackbarState from "../recoil/AlertSnackbarState"; 3 | 4 | /** 5 | * Hooks for Displaying Alert 6 | * @returns 7 | */ 8 | const useAlertSnackbar = () => { 9 | const [, setOpen] = useRecoilState(AlertSnackbarState.open); 10 | const [, setMessage] = useRecoilState(AlertSnackbarState.message); 11 | const [, setSeveriry] = useRecoilState(AlertSnackbarState.severity); 12 | 13 | return { 14 | openSucces: (message: string) => { 15 | setOpen(true); 16 | setMessage(message); 17 | setSeveriry("success"); 18 | }, 19 | openError: (message: string) => { 20 | setOpen(true); 21 | setMessage(message); 22 | setSeveriry("error"); 23 | }, 24 | }; 25 | }; 26 | 27 | export default useAlertSnackbar; 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { CognitoIdToken } from "amazon-cognito-identity-js"; 2 | import { Auth } from "aws-amplify"; 3 | import { useEffect, useState } from "react"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | const KEY_DOWNLOADABLE = "custom:Downloadable"; 7 | 8 | /** 9 | * Hooks for authentication 10 | * @returns 11 | */ 12 | const useAuth = () => { 13 | const [tokenPayload, setTokenPayload] = useState( 14 | {} 15 | ); 16 | const [canExport, setCanExport] = useState(false); 17 | const navigate = useNavigate(); 18 | 19 | // Get payload from ID token in session 20 | useEffect(() => { 21 | Auth.currentSession().then((sess) => { 22 | setTokenPayload(sess.getIdToken().payload); 23 | }); 24 | }, []); 25 | 26 | // Get attributes and set state 27 | useEffect(() => { 28 | setCanExport(tokenPayload[KEY_DOWNLOADABLE] === "true"); 29 | }, [tokenPayload]); 30 | 31 | return { 32 | canExport, 33 | signOut: () => { 34 | return Auth.signOut().then(() => { 35 | navigate("/"); 36 | }); 37 | }, 38 | }; 39 | }; 40 | 41 | export default useAuth; 42 | -------------------------------------------------------------------------------- /frontend/src/hooks/useHttp.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from "aws-amplify"; 2 | import axios, { AxiosError, AxiosResponse } from "axios"; 3 | import useSWR from "swr"; 4 | import useAlertSnackbar from "./useAlertSnackbar"; 5 | 6 | const api = axios.create({ 7 | baseURL: import.meta.env.VITE_API_URL, 8 | }); 9 | 10 | // HTTP Request Preprocessing 11 | api.interceptors.request.use(async (config) => { 12 | // If Authenticated, append ID Token to Request Header 13 | const user = await Auth.currentAuthenticatedUser(); 14 | if (user) { 15 | const token = (await Auth.currentSession()).getIdToken().getJwtToken(); 16 | config.headers["Authorization"] = token; 17 | } 18 | config.headers["Content-Type"] = "application/json"; 19 | 20 | return config; 21 | }); 22 | 23 | const fetcher = (url: string) => { 24 | return api.get(url).then((res) => res.data); 25 | }; 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | const getErrorMessage = (error: AxiosError): string => { 29 | return error.response?.data?.message ?? error.message; 30 | }; 31 | 32 | /** 33 | * Hooks for Http Request 34 | * @returns 35 | */ 36 | const useHttp = () => { 37 | const alert = useAlertSnackbar(); 38 | 39 | return { 40 | /** 41 | * GET Request 42 | * Implemented with SWR 43 | * @param url 44 | * @returns 45 | */ 46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 | get: (url: string | null) => { 48 | return useSWR(url, fetcher, { 49 | revalidateOnMount: true, 50 | revalidateOnFocus: false, 51 | revalidateOnReconnect: false, 52 | }); 53 | }, 54 | 55 | /** 56 | * POST Request 57 | * @param url 58 | * @param data 59 | * @returns 60 | */ 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | post: ( 63 | url: string, 64 | data: DATA, 65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 | errorProcess?: (err: any) => void 67 | ) => { 68 | return new Promise>((resolve, reject) => { 69 | api 70 | .post, DATA>(url, data) 71 | .then((data) => { 72 | resolve(data); 73 | }) 74 | .catch((err) => { 75 | if (errorProcess) { 76 | errorProcess(err); 77 | } else { 78 | alert.openError(getErrorMessage(err)); 79 | } 80 | reject(err); 81 | }); 82 | }); 83 | }, 84 | 85 | /** 86 | * PUT Request 87 | * @param url 88 | * @param data 89 | * @returns 90 | */ 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | put: ( 93 | url: string, 94 | data: DATA, 95 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 | errorProcess?: (err: any) => void 97 | ) => { 98 | return new Promise>((resolve, reject) => { 99 | api 100 | .put, DATA>(url, data) 101 | .then((data) => { 102 | resolve(data); 103 | }) 104 | .catch((err) => { 105 | if (errorProcess) { 106 | errorProcess(err); 107 | } else { 108 | alert.openError(getErrorMessage(err)); 109 | } 110 | reject(err); 111 | }); 112 | }); 113 | }, 114 | /** 115 | * DELETE Request 116 | * @param url 117 | * @returns 118 | */ 119 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 120 | delete: ( 121 | url: string, 122 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 123 | errorProcess?: (err: any) => void 124 | ) => { 125 | return new Promise>((resolve, reject) => { 126 | api 127 | .delete, DATA>(url) 128 | .then((data) => { 129 | resolve(data); 130 | }) 131 | .catch((err) => { 132 | if (errorProcess) { 133 | errorProcess(err); 134 | } else { 135 | alert.openError(getErrorMessage(err)); 136 | } 137 | reject(err); 138 | }); 139 | }); 140 | }, 141 | }; 142 | }; 143 | 144 | export default useHttp; 145 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useRecoilState } from "recoil"; 3 | import LoadingState from "../recoil/LoadingState"; 4 | 5 | /** 6 | * Hooks for displaying loading modal 7 | * @returns 8 | */ 9 | const useLoading = () => { 10 | const { t } = useTranslation(); 11 | const [, setOpen] = useRecoilState(LoadingState.open); 12 | const [, setMessage] = useRecoilState(LoadingState.message); 13 | 14 | return { 15 | open: (message?: string) => { 16 | setOpen(true); 17 | setMessage(message ?? t("app.message.loading")); 18 | }, 19 | close: () => { 20 | setOpen(false); 21 | }, 22 | }; 23 | }; 24 | 25 | export default useLoading; 26 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLocale.ts: -------------------------------------------------------------------------------- 1 | import { enUS, jaJP, Localization } from "@mui/material/locale"; 2 | import { MRT_Localization } from "material-react-table"; 3 | import { MRT_Localization_EN } from "material-react-table/locales/en"; 4 | import { MRT_Localization_JA } from "material-react-table/locales/ja"; 5 | import { useEffect, useState } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | /** 9 | * Hooks for i18n locale 10 | * @returns 11 | */ 12 | const useLocale = () => { 13 | const { i18n } = useTranslation(); 14 | 15 | const [mrtLocale, setMrtLocale] = useState(); 16 | const [muiLocale, setMuiLocale] = useState(); 17 | 18 | useEffect(() => { 19 | // set locale for MRT, MUI 20 | if (i18n.language === "en") { 21 | setMrtLocale(MRT_Localization_EN); 22 | setMuiLocale(enUS); 23 | } else if (i18n.language === "ja") { 24 | setMrtLocale(MRT_Localization_JA); 25 | setMuiLocale(jaJP); 26 | } 27 | }, [i18n.language]); 28 | 29 | return { 30 | mrtLocale, 31 | muiLocale, 32 | }; 33 | }; 34 | 35 | export default useLocale; 36 | -------------------------------------------------------------------------------- /frontend/src/hooks/useMuiTheme.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from "@mui/material"; 2 | import { useEffect } from "react"; 3 | import { atom, useRecoilState } from "recoil"; 4 | 5 | type ThemeMode = "dark" | "light"; 6 | const themeModeState = atom({ 7 | key: "themeMode", 8 | default: "light", 9 | }); 10 | 11 | const KEY = "muiThemeMode"; 12 | 13 | /** 14 | * Hooks for MUI Theme 15 | * 16 | * @returns 17 | */ 18 | const useMuiTheme = () => { 19 | const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); 20 | 21 | // Theme settings persist in LocalStrage. 22 | const savedTheme = localStorage.getItem(KEY) as ThemeMode | null; 23 | const [themeMode, setThemeMode] = useRecoilState(themeModeState); 24 | 25 | // If theme settings are not saved, set the theme according to the OS settings. 26 | useEffect(() => { 27 | setThemeMode(savedTheme ?? (prefersDarkMode ? "dark" : "light")); 28 | }, []); 29 | 30 | useEffect(() => { 31 | localStorage.setItem(KEY, themeMode ?? "light"); 32 | }, [themeMode]); 33 | 34 | return { 35 | themeMode, 36 | isDark: themeMode === "dark", 37 | switchMode: () => { 38 | setThemeMode(themeMode === "light" ? "dark" : "light"); 39 | }, 40 | }; 41 | }; 42 | 43 | export default useMuiTheme; 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSortableDnD.ts: -------------------------------------------------------------------------------- 1 | import type { Identifier, XYCoord } from "dnd-core"; 2 | import { useRef } from "react"; 3 | import { useDrag, useDrop } from "react-dnd"; 4 | 5 | type DragItem = { 6 | index: number; 7 | id: string; 8 | }; 9 | 10 | type Args = { 11 | id: string; 12 | type: string; 13 | index: number; 14 | moveItem: (dragIndex: number, hoverIndex: number) => void; 15 | }; 16 | 17 | /** 18 | * Hooks for Drag & Drop sorting 19 | * @param param0 20 | * @returns 21 | */ 22 | const useSortableDnD = ({ id, index, moveItem, type }: Args) => { 23 | const ref = useRef(null); 24 | 25 | // https://react-dnd.github.io/react-dnd/examples/sortable/simple 26 | const [{ handlerId }, drop] = useDrop< 27 | DragItem, 28 | void, 29 | { handlerId: Identifier | null } 30 | >({ 31 | accept: type, 32 | collect(monitor) { 33 | return { 34 | handlerId: monitor.getHandlerId(), 35 | }; 36 | }, 37 | // Mouse hover 38 | hover(item: DragItem, monitor) { 39 | if (!ref.current) { 40 | return; 41 | } 42 | const dragIndex = item.index; 43 | const hoverIndex = index; 44 | 45 | // Don't replace items with themselves 46 | if (dragIndex === hoverIndex) { 47 | return; 48 | } 49 | 50 | // Determine rectangle on screen 51 | const hoverBoundingRect = ref.current?.getBoundingClientRect(); 52 | 53 | // Get vertical middle 54 | const hoverMiddleY = 55 | (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; 56 | 57 | // Determine mouse position 58 | const clientOffset = monitor.getClientOffset(); 59 | 60 | // Get pixels to the top 61 | const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; 62 | 63 | // Only perform the move when the mouse has crossed half of the items height 64 | // When dragging downwards, only move when the cursor is below 50% 65 | // When dragging upwards, only move when the cursor is above 50% 66 | 67 | // Dragging downwards 68 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { 69 | return; 70 | } 71 | 72 | // Dragging upwards 73 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { 74 | return; 75 | } 76 | 77 | // Time to actually perform the action 78 | moveItem(dragIndex, hoverIndex); 79 | 80 | // Note: we're mutating the monitor item here! 81 | // Generally it's better to avoid mutations, 82 | // but it's good here for the sake of performance 83 | // to avoid expensive index searches. 84 | item.index = hoverIndex; 85 | }, 86 | }); 87 | 88 | const [{ isDragging }, drag] = useDrag({ 89 | type: type, 90 | item: (): DragItem => { 91 | return { id, index }; 92 | }, 93 | collect: (monitor) => ({ 94 | isDragging: monitor.isDragging(), 95 | }), 96 | }); 97 | 98 | drag(drop(ref)); 99 | 100 | return { 101 | ref, 102 | isDragging, 103 | handlerId, 104 | }; 105 | }; 106 | 107 | export default useSortableDnD; 108 | -------------------------------------------------------------------------------- /frontend/src/i18n/en/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | translation: { 3 | app: { 4 | name: "Data Exporter", 5 | authority: { 6 | readonly: "Read only", 7 | downloadable: "Downloadable", 8 | }, 9 | sideMenu: { 10 | myPage: "MyPage", 11 | newReport: "New Report", 12 | }, 13 | settings: { 14 | language: "Language", 15 | switchTheme: "Switch Theme", 16 | signOut: "Sign Out", 17 | }, 18 | language: { 19 | en: "English", 20 | ja: "日本語", 21 | }, 22 | field: { 23 | mailaddress: "email address", 24 | conditionOperator: "Condition", 25 | }, 26 | button: { 27 | register: "Register", 28 | ok: "OK", 29 | cancel: "Cancel", 30 | }, 31 | message: { 32 | loading: "Please wait...", 33 | }, 34 | error: { 35 | downloadDenied: "You don't have permission to download.", 36 | }, 37 | stringOperator: { 38 | eq: "equal to", 39 | neq: "NOT equal to", 40 | contains: "contains", 41 | }, 42 | numberOperator: { 43 | eq: "=", 44 | neq: "≠", 45 | gt: ">", 46 | gte: "≧", 47 | lt: "<", 48 | lte: "≦", 49 | }, 50 | selectLanguageDialog: { 51 | title: "Language Settings", 52 | label: "Language", 53 | }, 54 | }, 55 | myPage: { 56 | shareReportDialog: { 57 | title: "Share Report", 58 | description: 59 | "
Share [{{reportName}}] report.
Enter the email address to which you would like to share the report.
", 60 | button: { 61 | share: "Share", 62 | }, 63 | }, 64 | menu: { 65 | savedReport: "Saved Report", 66 | sharedReport: "Shared Report", 67 | extractionHistory: "Extraction History", 68 | }, 69 | reportList: { 70 | message: { 71 | noSavedReport: "No Saved Report.", 72 | noSharedReport: "No Shared Report.", 73 | shareSuccess: "Shared the report with {{userName}}.", 74 | }, 75 | button: { 76 | copy: "Copy", 77 | share: "Share", 78 | }, 79 | }, 80 | extractionHistory: { 81 | message: { 82 | noHistory: "No Extraction History.", 83 | }, 84 | field: { 85 | tableName: "Table", 86 | extractedData: "Columns", 87 | extractedCondition: "Conditions", 88 | extractedDatetime: "Datetime", 89 | }, 90 | stringOperator: { 91 | eq: { 92 | notEmpty: "is equal to {{value}}", 93 | empty: "is empty", 94 | }, 95 | neq: { 96 | notEmpty: "is NOT equal to{{value}}", 97 | empty: "is NOT empty", 98 | }, 99 | contains: "is contains {{value}}", 100 | }, 101 | relativeDateOperator: { 102 | today: "is today", 103 | yesterday: "is yesterday", 104 | lastWeek: "is last week", 105 | lastMonth: "is last month", 106 | lastYear: "is last year", 107 | }, 108 | relativeDateNOperator: { 109 | day_one: "from {{value}} day ago", 110 | day_other: "from {{value}} days ago", 111 | week_one: "from {{value}} week ago", 112 | week_other: "from {{value}} weeks ago", 113 | month_one: "from {{value}} month ago", 114 | month_other: "from {{value}} months ago", 115 | year_one: "from {{value}} year ago", 116 | year_other: "from {{value}} years ago", 117 | }, 118 | absoluteDateOperator: 119 | "from {{from}} to {{to}} ", 120 | }, 121 | button: { 122 | newReport: "New Report", 123 | }, 124 | }, 125 | reportPage: { 126 | field: { 127 | targetTable: "Table", 128 | reportName: "Report Name", 129 | unsavedReport: "[Unsaved Report]", 130 | extractResult: "Extract Result", 131 | columns: "Columns", 132 | selectColumns: "Select Columns", 133 | extractConditions: "Conditions", 134 | extractConditionValue: "Value", 135 | prefixCopiedReport: "[Copied]", 136 | }, 137 | button: { 138 | gotoConditionPage: "back to condition page", 139 | saveReport: "Save Report", 140 | export: "Export", 141 | extract: "Extract", 142 | itemSort: "Sort", 143 | }, 144 | message: { 145 | selectTable: "Please select a table.", 146 | selectMultipleColumnsNotice: 147 | "By holding down the Shift key and clicking on a column name, you can sort multiple columns.", 148 | succesSaveReport: "Saved the report.", 149 | }, 150 | error: { 151 | requiredColumns: "Select columns is a required field.", 152 | requiredConditions: "Conditions is a required field.", 153 | }, 154 | registerReportDialog: { 155 | title: "Save Report", 156 | description: "Save the report with the currently set conditions.", 157 | }, 158 | dateConditions: { 159 | relative: "Relative", 160 | relativeN: "Relative (N value)", 161 | absolute: "Absolute", 162 | today: "Today", 163 | yesterday: "Yesterday", 164 | lastWeek: "Last Week", 165 | lastMonth: "Last Month", 166 | lastYear: "Last Year", 167 | dayBefore: "day ago", 168 | weekBefore: "week ago", 169 | monthBofore: "month ago", 170 | yearBefore: "year ago", 171 | }, 172 | }, 173 | }, 174 | }; 175 | -------------------------------------------------------------------------------- /frontend/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import detector from "i18next-browser-languagedetector"; 3 | import { initReactI18next } from "react-i18next"; 4 | import en from "./en"; 5 | import ja from "./ja"; 6 | 7 | // import dayjs locale file for the language to be used.(use with mui LocalizationProvider) 8 | import "dayjs/locale/ja"; 9 | 10 | export const LANGUAGES = ["en", "ja"] as const; 11 | 12 | const resources = { 13 | en, 14 | ja, 15 | }; 16 | 17 | // Settings i18n 18 | const i18n = i18next 19 | .use(initReactI18next) 20 | .use(detector) 21 | .init({ 22 | resources, 23 | fallbackLng: "en", 24 | interpolation: { 25 | escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape 26 | }, 27 | }); 28 | 29 | export default i18n; 30 | -------------------------------------------------------------------------------- /frontend/src/i18n/ja/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | translation: { 3 | app: { 4 | name: "Data Exporter", 5 | authority: { 6 | readonly: "閲覧権限", 7 | downloadable: "ダウンロード権限", 8 | }, 9 | sideMenu: { 10 | myPage: "マイページ", 11 | newReport: "レポート新規作成", 12 | }, 13 | settings: { 14 | language: "言語", 15 | switchTheme: "テーマ切替", 16 | signOut: "サインアウト", 17 | }, 18 | language: { 19 | en: "English", 20 | ja: "日本語", 21 | }, 22 | field: { 23 | mailaddress: "メールアドレス", 24 | conditionOperator: "条件", 25 | }, 26 | button: { 27 | register: "登録", 28 | ok: "OK", 29 | cancel: "キャンセル", 30 | }, 31 | message: { 32 | loading: "処理中です。少々お待ちください。", 33 | }, 34 | error: { 35 | downloadDenied: "ダウンロード権限がありません。", 36 | }, 37 | stringOperator: { 38 | eq: "次に一致", 39 | neq: "次に一致しない", 40 | contains: "次を含む", 41 | }, 42 | numberOperator: { 43 | eq: "=", 44 | neq: "≠", 45 | gt: ">", 46 | gte: "≧", 47 | lt: "<", 48 | lte: "≦", 49 | }, 50 | selectLanguageDialog: { 51 | title: "言語切り替え", 52 | label: "Language", 53 | }, 54 | }, 55 | myPage: { 56 | shareReportDialog: { 57 | title: "レポート共有", 58 | description: 59 | "
レポート「{{reportName}}」を共有します。
共有先のメールアドレスを入力してください。
", 60 | button: { 61 | share: "共有", 62 | }, 63 | }, 64 | menu: { 65 | savedReport: "保存したレポート", 66 | sharedReport: "共有されたレポート", 67 | extractionHistory: "抽出履歴", 68 | }, 69 | reportList: { 70 | message: { 71 | noSavedReport: "保存したレポートがありません。", 72 | noSharedReport: "共有されたレポートがありません。", 73 | shareSuccess: "{{userName}}さんにレポートを共有しました。", 74 | }, 75 | button: { 76 | copy: "複製", 77 | share: "共有", 78 | }, 79 | }, 80 | extractionHistory: { 81 | message: { 82 | noHistory: "抽出履歴がありません。", 83 | }, 84 | field: { 85 | tableName: "テーブル名", 86 | extractedData: "抽出したデータ", 87 | extractedCondition: "抽出条件", 88 | extractedDatetime: "実行日時", 89 | }, 90 | stringOperator: { 91 | eq: { 92 | notEmpty: "が{{value}}と一致する", 93 | empty: "が未設定である", 94 | }, 95 | neq: { 96 | notEmpty: "が{{value}}と一致しない", 97 | empty: "が設定されている", 98 | }, 99 | contains: "が{{value}}を含む", 100 | }, 101 | relativeDateOperator: { 102 | today: "が 本日", 103 | yesterday: "が 昨日", 104 | lastWeek: "が 先週", 105 | lastMonth: "が 先月", 106 | lastYear: "が 昨年", 107 | }, 108 | relativeDateNOperator: { 109 | day: "が {{value}} 日前", 110 | week: "が {{value}} 週間前", 111 | month: "が {{value}} ヶ月前", 112 | year: "が {{value}} 年前", 113 | }, 114 | absoluteDateOperator: 115 | "{{from}}{{to}} ", 116 | }, 117 | button: { 118 | newReport: "レポート新規作成", 119 | }, 120 | }, 121 | reportPage: { 122 | field: { 123 | targetTable: "対象テーブル", 124 | reportName: "レポート名", 125 | unsavedReport: "保存されていないレポート", 126 | extractResult: "抽出結果", 127 | columns: "データ一覧", 128 | selectColumns: "抽出したいデータ", 129 | extractConditions: "抽出条件", 130 | extractConditionValue: "条件値", 131 | prefixCopiedReport: "[複製]", 132 | }, 133 | button: { 134 | gotoConditionPage: "条件設定画面へ", 135 | saveReport: "レポートを保存", 136 | export: "エクスポート", 137 | extract: "抽出実行", 138 | itemSort: "並び替え", 139 | }, 140 | message: { 141 | selectTable: "テーブルを選択してください", 142 | selectMultipleColumnsNotice: 143 | "Shitfキーを押しながら列名をクリックすることで、複数の列を同時に並び替えできます。", 144 | succesSaveReport: "レポートを保存しました。", 145 | }, 146 | error: { 147 | requiredColumns: "抽出したいデータを1件以上追加してください。", 148 | requiredConditions: "抽出条件を1件以上追加してください。", 149 | }, 150 | registerReportDialog: { 151 | title: "レポート保存", 152 | description: "現在設定されている条件でレポートを保存します。", 153 | }, 154 | dateConditions: { 155 | relative: "相対指定", 156 | relativeN: "相対指定(N値指定)", 157 | absolute: "絶対指定", 158 | today: "今日", 159 | yesterday: "昨日", 160 | lastWeek: "先週", 161 | lastMonth: "先月", 162 | lastYear: "昨年", 163 | dayBefore: "日前", 164 | weekBefore: "週前", 165 | monthBofore: "ヶ月前", 166 | yearBefore: "年前", 167 | }, 168 | }, 169 | }, 170 | }; 171 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", 3 | Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 4 | line-height: 1.5; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline } from "@mui/material"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import App from "./App"; 5 | 6 | import "./index.css"; 7 | import AppProvider from "./providers/AppProvider"; 8 | 9 | // アプリ全体で利用するライブラリのProviderの設定を行う 10 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /frontend/src/providers/AlertSnackbarProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Snackbar } from "@mui/material"; 2 | import React, { ReactNode } from "react"; 3 | import { useRecoilState, useRecoilValue } from "recoil"; 4 | import AlertSnackbarState from "../recoil/AlertSnackbarState"; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | }; 9 | 10 | /** 11 | * Provider for Displaying alert 12 | * @param param0 13 | * @returns 14 | */ 15 | const AlertSnackbarProvider: React.FC = ({ children }) => { 16 | const [open, setOpen] = useRecoilState(AlertSnackbarState.open); 17 | const message = useRecoilValue(AlertSnackbarState.message); 18 | const severity = useRecoilValue(AlertSnackbarState.severity); 19 | 20 | const handleClose = ( 21 | event?: React.SyntheticEvent | Event, 22 | reason?: string 23 | ) => { 24 | if (reason === "clickaway") { 25 | return; 26 | } 27 | setOpen(false); 28 | }; 29 | 30 | return ( 31 | <> 32 | 38 | 44 | {message} 45 | 46 | 47 | {children} 48 | 49 | ); 50 | }; 51 | 52 | export default AlertSnackbarProvider; 53 | -------------------------------------------------------------------------------- /frontend/src/providers/AppProvider.tsx: -------------------------------------------------------------------------------- 1 | import { AmplifyProvider } from "@aws-amplify/ui-react"; 2 | import { LocalizationProvider } from "@mui/x-date-pickers"; 3 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 4 | import React, { ReactNode } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import { BrowserRouter } from "react-router-dom"; 7 | import { RecoilRoot } from "recoil"; 8 | import "../i18n"; 9 | import AlertSnackbarProvider from "./AlertSnackbarProvider"; 10 | import LoadingProvider from "./LoadingProvider"; 11 | import MuiThemeProvider from "./MuiThemeProvider"; 12 | 13 | const AppProvider: React.FC<{ 14 | children: ReactNode; 15 | }> = ({ children }) => { 16 | const { i18n } = useTranslation(); 17 | return ( 18 | 19 | 20 | 21 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default AppProvider; 38 | -------------------------------------------------------------------------------- /frontend/src/providers/LoadingProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Modal } from "@mui/material"; 2 | import React, { ReactNode } from "react"; 3 | import { useRecoilValue } from "recoil"; 4 | import LoadingState from "../recoil/LoadingState"; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | }; 9 | 10 | /** 11 | * Provider for displaying loafing modal 12 | * @param param0 13 | * @returns 14 | */ 15 | const LoadingProvider: React.FC = ({ children }) => { 16 | const open = useRecoilValue(LoadingState.open); 17 | const message = useRecoilValue(LoadingState.message); 18 | 19 | return ( 20 | <> 21 | {/* loading modal */} 22 | 23 | 33 | 34 | 35 | 36 | {message} 37 | 38 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | export default LoadingProvider; 45 | -------------------------------------------------------------------------------- /frontend/src/providers/MuiThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme, ThemeProvider } from "@mui/material"; 2 | import React, { ReactNode, useMemo } from "react"; 3 | import useLocale from "../hooks/useLocale"; 4 | import useMuiTheme from "../hooks/useMuiTheme"; 5 | 6 | const MuiThemeProvider: React.FC<{ 7 | children: ReactNode; 8 | }> = ({ children }) => { 9 | const { themeMode } = useMuiTheme(); 10 | const { muiLocale } = useLocale(); 11 | 12 | const theme = useMemo(() => { 13 | return createTheme( 14 | { 15 | palette: { 16 | mode: themeMode, 17 | }, 18 | }, 19 | muiLocale ?? {} 20 | ); 21 | }, [themeMode, muiLocale]); 22 | return {children}; 23 | }; 24 | 25 | export default MuiThemeProvider; 26 | -------------------------------------------------------------------------------- /frontend/src/recoil/AlertSnackbarState.ts: -------------------------------------------------------------------------------- 1 | import { AlertColor } from "@mui/material"; 2 | import { atom } from "recoil"; 3 | /** 4 | * AlertSnackbarState 5 | */ 6 | 7 | /** 8 | * for open alert 9 | */ 10 | const open = atom({ 11 | key: "openAlertSnackbar", 12 | default: false, 13 | }); 14 | 15 | /** 16 | * displaying message 17 | */ 18 | const message = atom({ 19 | key: "alertSnackbarMessage", 20 | default: "", 21 | }); 22 | 23 | /** 24 | * severity of alert 25 | */ 26 | const severity = atom({ 27 | key: "alertSnackbarSeverity", 28 | default: "error", 29 | }); 30 | 31 | const AlertSnackbarState = { 32 | open, 33 | message, 34 | severity, 35 | }; 36 | 37 | export default AlertSnackbarState; 38 | -------------------------------------------------------------------------------- /frontend/src/recoil/DrawerState.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | /** 3 | * DrawerState 4 | */ 5 | 6 | /** 7 | * DrawerOpen 8 | */ 9 | const open = atom({ 10 | key: "drawerOpen", 11 | default: false, 12 | }); 13 | 14 | const DrawerState = { 15 | open, 16 | }; 17 | 18 | export default DrawerState; 19 | -------------------------------------------------------------------------------- /frontend/src/recoil/LoadingState.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | /** 3 | * LoadingState 4 | */ 5 | 6 | /** 7 | * open loading 8 | */ 9 | const open = atom({ 10 | key: "openLoading", 11 | default: false, 12 | }); 13 | 14 | /** 15 | * displaying message 16 | */ 17 | const message = atom({ 18 | key: "loadingMessage", 19 | default: "", 20 | }); 21 | 22 | const LoadingState = { 23 | open, 24 | message, 25 | }; 26 | 27 | export default LoadingState; 28 | -------------------------------------------------------------------------------- /frontend/src/utils/DateUtils.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { ConfigType } from "dayjs"; 2 | import timezone from "dayjs/plugin/timezone"; 3 | import utc from "dayjs/plugin/utc"; 4 | const FORMAT_DATETIME = "YYYY-MM-DD HH:mm:ss"; 5 | 6 | dayjs.extend(utc); 7 | dayjs.extend(timezone); 8 | 9 | /** 10 | * format date in datetime format 11 | * 12 | * @param date 13 | * @returns 14 | */ 15 | export const formatDatetime = (date: ConfigType): string => { 16 | return dayjs(date).tz().format(FORMAT_DATETIME); 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "exclude": ["cdk"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { alias: { "./runtimeConfig": "./runtimeConfig.browser" } }, 8 | }); 9 | --------------------------------------------------------------------------------