diff --git a/README.md b/README.md index 4cd6a878..60ae8103 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,10 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +--- + +Branded icons are licensed under their copyright license.


diff --git a/packages/rath-client/package.json b/packages/rath-client/package.json index 7a92a21a..f04c30e4 100644 --- a/packages/rath-client/package.json +++ b/packages/rath-client/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.10.4", "@fluentui/font-icons-mdl2": "^8.4.13", "@fluentui/react": "^8.94.4", + "@fluentui/react-file-type-icons": "^8.8.3", "@fluentui/react-hooks": "^8.6.11", "@kanaries/graphic-walker": "0.2.8", "@kanaries/loa": "^0.0.16", @@ -46,7 +47,8 @@ "vega-scenegraph": "4.10.1-kanaries-patch", "visual-insights": "^0.12.3", "web-vitals": "^0.2.4", - "worker-loader": "^3.0.7" + "worker-loader": "^3.0.7", + "xlsx": "^0.18.5" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.8", diff --git a/packages/rath-client/public/locales/en-US.json b/packages/rath-client/public/locales/en-US.json index c346aeb9..23d3e279 100644 --- a/packages/rath-client/public/locales/en-US.json +++ b/packages/rath-client/public/locales/en-US.json @@ -63,7 +63,7 @@ "config": { "computationEngine": { "title": "Computation Engine", - "desc": "Define where the analysis task run. If your dataset is small (< 100MB), web worker mode is recommanded. Otherwise try clickhosue mode.", + "desc": "Define where the analysis task run. If your dataset is small (< 100MB), web worker mode is recommended. Otherwise try clickhouse mode.", "clickhouse": "ClickHouse", "webworker": "Browser(Web Worker)", "notCompatible": "Not compatible with datasource" @@ -120,6 +120,7 @@ "dataView": "Table View", "statView": "Statistics View", "charset": "character encoding", + "separator": "Separator", "databaseType": "Select Database", "connectUri": "Connection URI", "databaseName": "Database", @@ -138,6 +139,17 @@ "originStatTable": "Original Data Statistics", "selectionStatTable": "Selection Data Statistics" }, + "sizeInfo": "{nCols} fields, {nRows} rows", + "demoDataset": { + "CARS": { "title": "Cars", "description": "Origin, product name and physical attributes of cars." }, + "STUDENTS": { "title": "Students", "description": "" }, + "BTC_GOLD": { "title": "BTC - Gold", "description": "" }, + "CAR_SALES": { "title": "Car Sales", "description": "" }, + "COLLAGE": { "title": "Collage", "description": "" }, + "TITANIC": { "title": "Titanic", "description": "" }, + "KEPLER": { "title": "Kepler", "description": "" }, + "BIKE_SHARING_DC": { "title": "Bike Sharing", "description": "" } + }, "dbProgress": [ { "label": "Connection", @@ -174,8 +186,9 @@ "lackData": "Lack of Data", "lackDimension": "It seems you don't have dimensions in your dataset. (part of functions of mega-automation maybe influenced.)", "lackMeasure": "It seems you don't have measures in your dataset. (part of functions of mega-automation maybe influenced.)", - "smallSample": "The sample size is not big enough, which may influence the reliability of recommandation.", - "forceAnalysis": "Force Analysis" + "smallSample": "The sample size is not big enough, which may influence the reliability of recommendation.", + "forceAnalysis": "Force Analysis", + "upload_file_too_large": "This file is too large. Use a smaller subset or transform it into CSV file to apply sampling." }, "meta": { "uniqueValue": "Unique Value", @@ -229,11 +242,37 @@ }, "upload": { "title": "Upload Your own dataset", - "fileTypes": "csv, json are supportted.", + "fileTypes": "Excel workbooks and text-based files (e.g. JSON, CSV) are supported.", "uniqueIdIssue": "Add unique ids for fields", + "show_more": "More Options", "sampling": "Sampling", "percentSize": "sample size(rows)", - "upload": "Upload" + "excel_range": "Range of cells", + "upload": "Upload", + "change": "Browse", + "lastOpen": "Last opened", + "firstOpen": "First created", + "history": "Open Recent", + "history_time": { + "1d": "Today", + "1w": "Last weak", + "1mo": "Last month", + "3mo": "Last 3 months", + "6mo": "Last half a year", + "1yr": "Last year" + }, + "new": "New file", + "sheet": "Sheet", + "preview_parsed": "Preview", + "preview_full": "Full", + "preview_raw": "Raw", + "data_is_empty": "This dataset is empty", + "separator": { + "comma": "Comma", + "semicolon": "Semicolon", + "tab": "Tab", + "other": "Other..." + } }, "exploreMode": { "title": "Explore Mode", @@ -353,7 +392,7 @@ "pin": "pin", "compare": "compare", "vizsys": { - "title": "visualization recommand system", + "title": "visualization recommend system", "lite": "Lite mode(fast)", "strict": "Strict mode" }, @@ -436,7 +475,7 @@ "$ReLU": "Projects a field by x -> max(0, x) as a new column.", "$match": "Finds the matched parts of each text content using Regular Expression as a new column.", "$replace": "Replaces the matched parts of each text content with a given string using Regular Expression as a new column.", - "$concat": "Concats text contents of all the fields as a new column.", + "$concat": "Merge two or more text contents of all the fields as a new column.", "$sigmoid": "Projects a field by x -> (1 + e^-x)^-1 as a new column.", "$boxClip": "Maps overflowing values (due to boxplot) to the domain as a new column.", "$meanClip": "Maps overflowing values (due to the given domain) to the mean of all the other values as a new column.", @@ -479,8 +518,8 @@ "shortMonths": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] }, "login": { - "clickLogin": "Click Login", - "haveSent": "Alredy Send", + "clickLogin": "Login", + "haveSent": "Already Sent", "signIn": "Sign In", "signOut": "Sign Out", "preferences": "Preferences", @@ -506,7 +545,7 @@ "errEmail": "not support email type" }, "password": { - "userName": "UsernNme", + "userName": "Username", "password": "Password" } } diff --git a/packages/rath-client/public/locales/zh-CN.json b/packages/rath-client/public/locales/zh-CN.json index f5c35f12..88917374 100644 --- a/packages/rath-client/public/locales/zh-CN.json +++ b/packages/rath-client/public/locales/zh-CN.json @@ -119,6 +119,7 @@ "dataView": "数据视图", "statView": "统计视图", "charset": "字符集编码", + "separator": "分隔符", "databaseType": "选择数据库", "connectUri": "连接 URI", "databaseName": "数据库", @@ -137,6 +138,17 @@ "originStatTable": "原始数据统计信息", "selectionStatTable": "筛选部分统计信息" }, + "sizeInfo": "{nCols} 列 x {nRows} 行", + "demoDataset": { + "CARS": { "title": "汽车数据集", "description": "记录了汽车的型号、厂商、物理参数的数据。" }, + "STUDENTS": { "title": "学生成绩数据集", "description": "分析学生的成绩和部分其他因素等关系。" }, + "BTC_GOLD": { "title": "比特币-金价数据集", "description": "" }, + "CAR_SALES": { "title": "汽车销售数据集", "description": "" }, + "COLLAGE": { "title": "大学数据集", "description": "" }, + "TITANIC": { "title": "泰坦尼克数据集", "description": "" }, + "KEPLER": { "title": "开普勒数据集", "description": "" }, + "BIKE_SHARING_DC": { "title": "共享单车数据集", "description": "" } + }, "dbProgress": [ { "label": "建立连接", @@ -174,7 +186,8 @@ "lackDimension": "数据集中缺少维度。(全自动化分析模块部分能力会受到影响)", "lackMeasure": "数据集中缺少度量,(全自动化分析模块会受到影响,可以尝试使用其他模块)", "smallSample": "数据集中样本数量低于预期,可能会对推荐结果的一般性造成影响。(小样本问题)", - "forceAnalysis": "强制分析" + "forceAnalysis": "强制分析", + "upload_file_too_large": "文件体积过大。尝试使用更少的数据,或将文件转换为 CSV 格式以使用采样功能。" }, "meta": { "title": "元数据视图", @@ -229,11 +242,37 @@ }, "upload": { "title": "连接你的数据集,根据需求调整以下配置", - "fileTypes": "支持csv, json文件", + "fileTypes": "支持 Excel 工作簿以及 JSON、CSV 等文本类型文件", "uniqueIdIssue": "添加唯一标识(字段是中文字符推荐使用)", + "show_more": "高级设置", "sampling": "数据采样", "percentSize": "样本大小(行)", - "upload": "上传文件" + "excel_range": "工作簿范围", + "upload": "上传文件", + "change": "重新选择", + "lastOpen": "上一次使用", + "firstOpen": "创建时间", + "history": "最近使用", + "history_time": { + "1d": "今天", + "1w": "过去一周", + "1mo": "过去一个月", + "3mo": "过去三个月", + "6mo": "过去半年", + "1yr": "一年内" + }, + "new": "新的文件", + "sheet": "工作簿", + "preview_parsed": "预览", + "preview_full": "完整内容", + "preview_raw": "原始内容", + "data_is_empty": "数据集是空的。", + "separator": { + "comma": "逗号", + "semicolon": "分号", + "tab": "制表符", + "other": "自定义..." + } }, "exploreMode": { "title": "探索模式", diff --git a/packages/rath-client/src/App.tsx b/packages/rath-client/src/App.tsx index b8876cea..58d37ddb 100644 --- a/packages/rath-client/src/App.tsx +++ b/packages/rath-client/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { observer } from 'mobx-react-lite'; import { Spinner, SpinnerSize } from '@fluentui/react'; import './App.css'; @@ -19,29 +19,8 @@ import Collection from './pages/collection'; import Dashboard from './pages/dashboard'; import CausalPage from './pages/causal'; import PerformanceWindow from './components/performance-window'; -import LoginInfo from './pages/loginInfo'; -import Account from './pages/loginInfo/account'; -import Setup from './pages/loginInfo/setup'; +import useHotKey from './hooks/use-hotkey'; -export enum PreferencesType { - Account = 'account', - Info = 'info', - Setting = 'setting', - Header = 'header' -} -export interface PreferencesListType { - key: PreferencesType; - name: PreferencesType; - icon: string; - element: () => JSX.Element; -} - -const preferencesList: PreferencesListType[] = [ - { key: PreferencesType.Account, name: PreferencesType.Account, icon: 'Home', element: () => }, - // { key: PreferencesType.Info, name: PreferencesType.Info, icon: 'Info', element: () => }, - // { key: PreferencesType.Header, name: PreferencesType.Header, icon: 'Contact', element: () =>

}, - { key: PreferencesType.Setting, name: PreferencesType.Setting, icon: 'Settings', element: () => }, -]; function App() { const { langStore, commonStore } = useGlobalStore(); @@ -60,6 +39,11 @@ function App() { }; }, [commonStore]); + const [showPerformanceWindow, setShowPerformanceWindow] = useState(false); + useHotKey({ + 'Control+Shift+P': () => setShowPerformanceWindow(on => !on), + }); + if (!langStore.loaded) { return (
@@ -68,20 +52,11 @@ function App() { ); } - const showPerformanceWindow = (new URL(window.location.href).searchParams.get('performance') ?? ( - JSON.stringify(process.env.NODE_ENV !== 'production') && false // temporarily banned this feature - )) === 'true'; - return (
- { - return ; - }} - preferencesList={preferencesList} - /> +
diff --git a/packages/rath-client/src/components/appNav.tsx b/packages/rath-client/src/components/appNav.tsx index 3c439112..0ce4263f 100644 --- a/packages/rath-client/src/components/appNav.tsx +++ b/packages/rath-client/src/components/appNav.tsx @@ -6,11 +6,13 @@ import styled from 'styled-components'; import { PIVOT_KEYS } from '../constants'; import { useGlobalStore } from '../store'; +import LoginInfo from '../pages/loginInfo'; import useHotKey from '../hooks/use-hotkey'; import UserSetting from './userSettings'; const NavContainer = styled.div` - height: 100%; + height: 100vh; + overflow: hidden auto; /* display: relative; */ position: relative; /* flex-direction: vertical; */ @@ -20,6 +22,9 @@ const NavContainer = styled.div` /* position: absolute; */ bottom: 0px; /* padding-left: 1em; */ + flex-grow: 0; + flex-shrink: 0; + overflow: hidden; } padding-left: 10px; .text-red { @@ -196,11 +201,12 @@ const AppNav: React.FC = (props) => { )} -
+
+
); diff --git a/packages/rath-client/src/constants.ts b/packages/rath-client/src/constants.ts index 15830c81..6ee19591 100644 --- a/packages/rath-client/src/constants.ts +++ b/packages/rath-client/src/constants.ts @@ -30,8 +30,8 @@ export const EXPLORE_MODE = { export const DEMO_DATA_REQUEST_TIMEOUT = 1000 * 10; export const ENGINE_CONNECTION_STAGES: Array<{ stage: number; name: IECStatus; description?: string }> = [ - { stage: 0, name: 'client', description: 'client module importetd.' }, - { stage: 1, name: 'proxy', description: 'database proxy connector lanuched.' }, + { stage: 0, name: 'client', description: 'client module imported.' }, + { stage: 1, name: 'proxy', description: 'database proxy connector launched.' }, { stage: 2, name: 'engine', description: 'clickhouse connected.' }, ]; @@ -53,3 +53,19 @@ export const STORAGES = { CONFIG: 'config', ITERATOR_META: 'iterator_meta', } + +export enum RATH_ENV { + DEV = 'development environment', + TEST = 'test environment', + LPE = 'local preview environment', + IPE = 'integrative preview environment', + ONLINE = 'online production environment', +} + +// This file is included in Worker so never forget to check if `window` is undefined!!!!! +export const RathEnv: RATH_ENV = ( + process.env.NODE_ENV === 'development' ? RATH_ENV.DEV + : process.env.NODE_ENV === 'test' ? RATH_ENV.TEST + : globalThis.window === undefined || globalThis.window?.location.host.match(/^(.*\.)?kanaries\.(net|cn)$/) ? RATH_ENV.ONLINE + : globalThis.window?.location.host.match(/^.*kanaries\.vercel\.app$/) ? RATH_ENV.IPE : RATH_ENV.LPE +); diff --git a/packages/rath-client/src/hooks/index.ts b/packages/rath-client/src/hooks/index.ts index 1fbb90ca..2f3c56eb 100644 --- a/packages/rath-client/src/hooks/index.ts +++ b/packages/rath-client/src/hooks/index.ts @@ -3,7 +3,7 @@ import produce, { Draft } from 'immer'; import intl from 'react-intl-universal'; import { CleanMethod } from '../interfaces'; import { notify } from '../components/error'; -import { getServerUrl } from '../utils/user'; +import { getMainServerUrl } from '../utils/user'; import { request } from '../utils/request'; /** @@ -56,7 +56,7 @@ export const useCleanMethodList = function (): typeof cleanMethodList { }; async function sendCertMail(email: string) { - const url = getServerUrl('/api/sendMailCert'); + const url = getMainServerUrl('/api/sendMailCert'); // TODO: [feat] email format check const res = await request.post<{ email: string }, string>(url, { email }); if (res) { @@ -66,7 +66,7 @@ async function sendCertMail(email: string) { } async function sendCertPhone(phone: string) { - const url = getServerUrl('/api/sendPhoneCert'); + const url = getMainServerUrl('/api/sendPhoneCert'); const res = await request.post<{ phone: string }, string>(url, { phone }); if (res) { // console.log("message sent success"); diff --git a/packages/rath-client/src/pages/dataSource/config.ts b/packages/rath-client/src/pages/dataSource/config.ts index d8f6f951..33be08a9 100644 --- a/packages/rath-client/src/pages/dataSource/config.ts +++ b/packages/rath-client/src/pages/dataSource/config.ts @@ -6,38 +6,38 @@ import { IDataSourceType } from "../../global"; export const useDataSourceTypeOptions = function (): Array<{ key: IDataSourceType; text: string }> { const fileText = intl.get(`dataSource.importData.type.${IDataSourceType.FILE}`); const restfulText = intl.get(`dataSource.importData.type.${IDataSourceType.RESTFUL}`); - const demoText = intl.get(`dataSource.importData.type.${IDataSourceType.DEMO}`) - const localText = intl.get('common.history'); + const demoText = intl.get(`dataSource.importData.type.${IDataSourceType.DEMO}`); const dbText = intl.get(`dataSource.importData.type.${IDataSourceType.DATABASE}`); + const historyText = intl.get('common.history'); const options = useMemo>(() => { return [ { - key: IDataSourceType.FILE, - text: fileText, - iconProps: { iconName: "ExcelDocument" }, + key: IDataSourceType.LOCAL, + text: historyText, + iconProps: { iconName: "History" }, }, { - key: IDataSourceType.LOCAL, - text: localText, + key: IDataSourceType.FILE, + text: fileText, iconProps: { iconName: "FabricUserFolder" }, - disabled: false, }, { key: IDataSourceType.DEMO, text: demoText, iconProps: { iconName: "FileTemplate" }, }, - { - key: IDataSourceType.RESTFUL, - text: restfulText, - iconProps: { iconName: "Cloud" }, - }, { key: IDataSourceType.DATABASE, text: dbText, iconProps: { iconName: "Database" } }, + { + key: IDataSourceType.AIRTABLE, + text: 'AirTable', + iconProps: { iconName: 'Table' }, + disabled: false + }, { key: IDataSourceType.OLAP, text: 'OLAP', @@ -45,13 +45,12 @@ export const useDataSourceTypeOptions = function (): Array<{ key: IDataSourceTyp disabled: false, }, { - key: IDataSourceType.AIRTABLE, - text: 'AirTable', - iconProps: { iconName: 'Table' }, - disabled: false + key: IDataSourceType.RESTFUL, + text: restfulText, + iconProps: { iconName: "Cloud" }, }, ]; - }, [fileText, restfulText, demoText, localText, dbText]); + }, [fileText, restfulText, demoText, dbText, historyText]); return options; }; @@ -63,7 +62,7 @@ export const DemoDataAssets = process.env.NODE_ENV === 'production' ? { CAR_SALES: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-carsales-service.json', COLLAGE: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-collage-service.json', TITANIC: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-titanic-service.json', - KELPER: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-kelper-service.json', + KEPLER: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-kelper-service.json', BIKE_SHARING_DC: 'https://chspace.oss-cn-hongkong.aliyuncs.com/api/bike_dc-dataset-service.json' } : { // CARS: "https://chspace.oss-cn-hongkong.aliyuncs.com/api/ds-cars-service.json", @@ -76,47 +75,63 @@ export const DemoDataAssets = process.env.NODE_ENV === 'production' ? { CAR_SALES: '/datasets/ds-carsales-service.json', COLLAGE: '/datasets/ds-collage-service.json', TITANIC: '/datasets/ds-titanic-service.json', - KELPER: '/datasets/ds-kelper-service.json', + KEPLER: '/datasets/ds-kelper-service.json', BIKE_SHARING_DC: '/datasets/bike_dc-dataset-service.json' } as const; export type IDemoDataKey = keyof typeof DemoDataAssets; -export const useDemoDataOptions = function (): Array<{key: IDemoDataKey; text: string}> { - const options = useMemo>(() => { +export const useDemoDataOptions = function (): Array<{key: IDemoDataKey; text: string; nCols: number; nRows: number}> { + const options = useMemo>(() => { return [ { key: "CARS", text: "Cars", + nCols: 9, + nRows: 406, }, { key: "STUDENTS", - text: "Students' Performance" + text: "Students' Performance", + nCols: 8, + nRows: 1000, }, { key: 'BIKE_SHARING_DC', - text: 'Bike Sharing in Washington D.C.' + text: 'Bike Sharing in Washington D.C.', + nCols: 16, + nRows: 17319, }, { key: "CAR_SALES", - text: "Car Sales" + text: "Car Sales", + nCols: 16, + nRows: 157, }, { key: "COLLAGE", - text: "Collage" + text: "Collage", + nCols: 16, + nRows: 1294, }, { - key: "KELPER", - text: "NASA Kelper" + key: "KEPLER", + text: "NASA Kepler", + nCols: 44, + nRows: 9218, }, { key: 'BTC_GOLD', - text: "2022MCM Problem C: Trading Strategies" + text: "2022MCM Problem C: Trading Strategies", + nCols: 7, + nRows: 464, }, { key: "TITANIC", - text: "Titanic" - } + text: "Titanic", + nCols: 11, + nRows: 712, + }, ]; }, []); return options; diff --git a/packages/rath-client/src/pages/dataSource/index.tsx b/packages/rath-client/src/pages/dataSource/index.tsx index 092222bd..4b3c5dbb 100644 --- a/packages/rath-client/src/pages/dataSource/index.tsx +++ b/packages/rath-client/src/pages/dataSource/index.tsx @@ -14,7 +14,7 @@ import { observer } from 'mobx-react-lite'; import { useGlobalStore } from '../../store'; import { IDataPrepProgressTag, IDataPreviewMode, IMuteFieldBase, IRow } from '../../interfaces'; import { Card } from '../../components/card'; -import { setDataStorage } from '../../utils/storage'; +import { DataSourceTag, IDBMeta, setDataStorage } from '../../utils/storage'; import { BorderCard } from '../../components/borderCard'; import DataTable from './dataTable/index'; import MetaView from './metaView/index'; @@ -73,11 +73,11 @@ const DataSourceBoard: React.FC = (props) => { }, [dataSourceStore]); const onSelectDataLoaded = useCallback( - (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => { + (fields: IMuteFieldBase[], dataSource: IRow[], name?: string, tag?: DataSourceTag | undefined, withHistory?: IDBMeta | undefined) => { dataSourceStore.loadDataWithInferMetas(dataSource, fields); - if (name) { + if (name && tag !== undefined) { dataSourceStore.setDatasetId(name); - setDataStorage(name, fields, dataSource); + setDataStorage(name, fields, dataSource, tag, withHistory); } }, [dataSourceStore] diff --git a/packages/rath-client/src/pages/dataSource/selection/airtable/index.tsx b/packages/rath-client/src/pages/dataSource/selection/airtable/index.tsx index 51174d5e..d49febf9 100644 --- a/packages/rath-client/src/pages/dataSource/selection/airtable/index.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/airtable/index.tsx @@ -4,6 +4,7 @@ import intl from 'react-intl-universal' import { logDataImport } from '../../../../loggers/dataImport'; import { IMuteFieldBase, IRow } from '../../../../interfaces'; import { transformRawDataService } from '../../utils'; +import { DataSourceTag, IDBMeta } from '../../../../utils/storage'; import { fetchAllRecordsFromAirTable } from './utils'; @@ -11,7 +12,7 @@ interface AirTableSourceProps { onClose: () => void; onStartLoading: () => void; onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: string, tag: DataSourceTag, withHistory?: IDBMeta | undefined) => void; } const AirTableSource: React.FC = (props) => { const { onClose, onDataLoaded, onLoadingFailed, onStartLoading } = props; @@ -33,7 +34,7 @@ const AirTableSource: React.FC = (props) => { .then((data) => transformRawDataService(data)) .then((ds) => { const name = `airtable-${tableName}-${viewName}`; - onDataLoaded(ds.fields, ds.dataSource, name); + onDataLoaded(ds.fields, ds.dataSource, name, DataSourceTag.AIR_TABLE); logDataImport({ dataType: 'AirTable', fields: ds.fields, diff --git a/packages/rath-client/src/pages/dataSource/selection/database/index.tsx b/packages/rath-client/src/pages/dataSource/selection/database/index.tsx index 6016b171..1561c151 100644 --- a/packages/rath-client/src/pages/dataSource/selection/database/index.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/database/index.tsx @@ -5,6 +5,7 @@ import type { IMuteFieldBase, IRow } from '../../../../interfaces'; import { logDataImport } from '../../../../loggers/dataImport'; import prefetch from '../../../../utils/prefetch'; import { notify } from '../../../../components/error'; +import { DataSourceTag } from '../../../../utils/storage'; import { transformRawDataService } from '../../utils'; import Progress from './progress'; import datasetOptions from './config'; @@ -59,7 +60,7 @@ export interface TableData { interface DatabaseDataProps { onClose: () => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: string, tag: DataSourceTag) => void; setLoadingAnimation: (on: boolean) => void; } @@ -203,7 +204,7 @@ const DatabaseData: React.FC = ({ onClose, onDataLoaded, setL dataSource: dataSource.slice(0, 10), size: dataSource.length, }); - onDataLoaded(fields, dataSource, name); + onDataLoaded(fields, dataSource, name, DataSourceTag.DATABASE); onClose(); } catch (error) { diff --git a/packages/rath-client/src/pages/dataSource/selection/demo.tsx b/packages/rath-client/src/pages/dataSource/selection/demo.tsx index f05e3dfa..3b2f65f9 100644 --- a/packages/rath-client/src/pages/dataSource/selection/demo.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/demo.tsx @@ -1,17 +1,21 @@ -import { ChoiceGroup, DefaultButton, Label } from '@fluentui/react'; -import React, { useCallback, useState } from 'react'; +import { Icon, Label } from '@fluentui/react'; +import { FC, useCallback, useMemo, useRef } from 'react'; import { useId } from "@fluentui/react-hooks"; import intl from 'react-intl-universal'; +import styled from 'styled-components'; import { DemoDataAssets, IDemoDataKey, useDemoDataOptions } from '../config'; import { logDataImport } from '../../../loggers/dataImport'; import { IDatasetBase, IMuteFieldBase, IRow } from '../../../interfaces'; import { DEMO_DATA_REQUEST_TIMEOUT } from '../../../constants'; +import { DataSourceTag, IDBMeta } from '../../../utils/storage'; +import useBoundingClientRect from '../../../hooks/use-bounding-client-rect'; +import getFileIcon from './history/get-file-icon'; interface DemoDataProps { onClose: () => void; onStartLoading: () => void; onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: string, tag: DataSourceTag, withHistory?: IDBMeta | undefined) => void; } function valueFix (ds: IDatasetBase): IDatasetBase { @@ -52,21 +56,91 @@ function requestDemoData (dsKey: IDemoDataKey = 'CARS'): Promise { // fields: [] // } // } -} +} + +export const RathDemoVirtualExt = 'rath-demo.json'; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + > label { + width: 100%; + } +`; + +const List = styled.div` + margin: 1em 0; + min-height: 8em; + max-height: 50vh; + width: 44vw; + overflow: hidden auto; + display: grid; + gap: 0.4em; + grid-auto-rows: max-content; +`; -const DemoData: React.FC = props => { +const ListItem = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + padding: 1.2em 1em 1em 1.4em; + border-radius: 2px; + position: relative; + box-shadow: inset 0 0 2px #8881; + > .head { + display: flex; + align-items: flex-start; + > i { + flex-grow: 0; + flex-shrink: 0; + width: 2em; + height: 2em; + margin-right: 0.8em; + user-select: none; + } + > div { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + overflow: hidden; + display: flex; + flex-direction: column; + > header { + font-size: 0.8rem; + line-height: 1.2em; + font-weight: 550; + color: #111; + margin-bottom: 0.4em; + } + > span { + word-break: break-all; + line-height: 1.2em; + margin: 0.12em 0; + } + } + } + :hover { + background-color: #8881; + } + cursor: pointer; +`; + +const ITEM_MIN_WIDTH = 240; + +const DemoData: FC = props => { const { onDataLoaded, onClose, onStartLoading, onLoadingFailed } = props; const options = useDemoDataOptions(); - const [dsKey, setDSKey] = useState('CARS'); - const loadData = useCallback(() => { + const loadDemo = useCallback((demo: typeof options[number]) => { onStartLoading(); - requestDemoData(dsKey).then(data => { + requestDemoData(demo.key).then(data => { const { dataSource, fields } = data; - onDataLoaded(fields, dataSource, 'rdemo_' + dsKey); + onDataLoaded(fields, dataSource, `${demo.text}.${RathDemoVirtualExt}`, DataSourceTag.DEMO); logDataImport({ dataType: "Demo", - name: dsKey, + name: demo.key, fields, dataSource: [], size: dataSource.length, @@ -75,25 +149,47 @@ const DemoData: React.FC = props => { onLoadingFailed(err); }) onClose(); - }, [dsKey, onDataLoaded, onClose, onStartLoading, onLoadingFailed]) + }, [onClose, onDataLoaded, onLoadingFailed, onStartLoading]); const labelId = useId('demo-ds'); + + const listRef = useRef(null); + const { width } = useBoundingClientRect(listRef, { width: true }); + const colCount = useMemo(() => Math.floor((width ?? (window.innerWidth * 0.6)) / ITEM_MIN_WIDTH), [width]); + return ( -
+ - { - if (option) { - setDSKey(option.key as IDemoDataKey); - } - }} - /> -
- -
-
+ + {options.map((demo, i) => { + return ( + loadDemo(demo)} + > +
+ +
+
+ {intl.get(`dataSource.demoDataset.${demo.key}.title`)} +
+ + {intl.get(`dataSource.demoDataset.${demo.key}.description`)} + + + {intl.get(`dataSource.sizeInfo`, demo)} + +
+
+
+ ); + })} +
+ ); } diff --git a/packages/rath-client/src/pages/dataSource/selection/file.tsx b/packages/rath-client/src/pages/dataSource/selection/file.tsx deleted file mode 100644 index 00c91485..00000000 --- a/packages/rath-client/src/pages/dataSource/selection/file.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useState, useRef, useMemo } from "react"; -import { ChoiceGroup, IChoiceGroupOption, SpinButton, DefaultButton, Dropdown, IDropdownOption } from '@fluentui/react'; -import { useId } from "@fluentui/react-hooks"; -import intl from "react-intl-universal"; -import { loadDataFile, SampleKey, useSampleOptions } from "../utils"; -import { dataBackup, logDataImport } from "../../../loggers/dataImport"; -import { IMuteFieldBase, IRow } from "../../../interfaces"; -interface FileDataProps { - onClose: () => void; - onStartLoading: () => void; - onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; - onDataLoading: (p: number) => void; -} -const FileData: React.FC = (props) => { - const { onClose, onDataLoaded, onStartLoading, onLoadingFailed, onDataLoading } = props; - const sampleOptions = useSampleOptions(); - const labelId = useId("labelElement"); - const [sampleMethod, setSampleMethod] = useState(SampleKey.none); - const [sampleSize, setSampleSize] = useState(500); - const fileEle = useRef(null); - const [chartset, setcharSet] = useState('utf-8'); - - const charsetOptions = useMemo(() => { - return [ - { - text: 'UTF-8', - key: 'utf-8' - }, - { - text: 'GB2312', - key: 'gb2312' - }, - { - text: 'US-ASCII', - key: 'us-ascii' - }, - { - text: 'Big5', - key: 'big5' - }, - { - text: 'Big5-HKSCS', - key: 'Big5-HKSCS' - }, - { - text: 'GB18030', - key: 'GB18030' - }, - ] - }, []) - - async function fileUploadHanlder() { - if (fileEle.current !== null && fileEle.current.files !== null) { - const file = fileEle.current.files[0]; - onStartLoading(); - try { - const { fields, dataSource } = await loadDataFile({ - file, - sampleMethod, - sampleSize, - encoding: chartset, - onLoading: onDataLoading - }); - logDataImport({ - dataType: 'File', - fields, - dataSource: dataSource.slice(0, 10), - size: dataSource.length - }); - dataBackup(file); - onDataLoaded(fields, dataSource, file.name); - } catch (error) { - onLoadingFailed(error) - } - onClose(); - } - } - - return ( -
-
-

{intl.get("dataSource.upload.fileTypes")}

-
-
- { - item && setcharSet(item.key as string) - }} - /> -
-
- { - if (option) { - setSampleMethod(option.key as SampleKey); - } - }} - ariaLabelledBy={labelId} - /> - {sampleMethod === SampleKey.reservoir && ( - { - setSampleSize(Number(value)); - }} - onIncrement={() => { - setSampleSize((v) => v + 1); - }} - onDecrement={() => { - setSampleSize((v) => Math.max(v - 1, 0)); - }} - /> - )} -
-
- - { - if (fileEle.current) { - fileEle.current.click(); - } - }} - /> -
-
- ); -}; - -export default FileData; diff --git a/packages/rath-client/src/pages/dataSource/selection/file/file-helper.tsx b/packages/rath-client/src/pages/dataSource/selection/file/file-helper.tsx new file mode 100644 index 00000000..756e02e6 --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/file/file-helper.tsx @@ -0,0 +1,263 @@ +import intl from 'react-intl-universal'; +import { ChoiceGroup, Dropdown, IChoiceGroupOption, Label, SpinButton, TextField } from "@fluentui/react"; +import { FC, useMemo, useState } from "react"; +import styled from "styled-components"; +import produce from 'immer'; +import { SampleKey, useSampleOptions } from '../../utils'; + + +export const charsetOptions = [ + { + text: 'UTF-8', + key: 'utf-8' + }, + { + text: 'GB2312', + key: 'gb2312' + }, + { + text: 'US-ASCII', + key: 'us-ascii' + }, + { + text: 'Big5', + key: 'big5' + }, + { + text: 'Big5-HKSCS', + key: 'Big5-HKSCS' + }, + { + text: 'GB18030', + key: 'GB18030' + }, +] as const; + +export type Charset = typeof charsetOptions[number]['key']; + +const Container = styled.div` + display: flex; + flex-direction: column; + padding-top: 1em; + & label { + font-weight: 400; + margin-right: 1em; + } + & [role=radiogroup] { + display: flex; + flex-direction: row; + align-items: center; + > div { + display: flex; + flex-direction: row; + > * { + margin: 0; + } + } + } + > * { + margin-bottom: 0.8em; + } + & .spin-group { + display: flex; + flex-direction: row; + align-items: center; + & * { + min-width: unset; + width: max-content; + height: max-content; + min-height: unset; + } + > * { + margin: 0 0.5em; + } + & input { + width: 3em; + } + } +`; + +export interface IFileHelperProps { + showMoreConfig: boolean; + charset: Charset; + setCharset: (charset: Charset) => void; + sampleMethod: SampleKey; + setSampleMethod: (sampleMethod: SampleKey) => void; + sampleSize: number; + setSampleSize: (sampleSize: number | ((prev: number) => number)) => void; + preview: File | null; + isExcel: boolean; + excelRef: [[number, number], [number, number]]; + excelRange: [[number, number], [number, number]]; + setExcelRange: (range: [[number, number], [number, number]]) => void; + sheetNames: string[] | false; + selectedSheetIdx: number; + setSelectedSheetIdx: (selectedSheetIdx: number) => void; + separator: string; + setSeparator: (separator: string) => void; +} + +const FileHelper: FC = ({ + showMoreConfig, + charset, setCharset, sampleMethod, setSampleMethod, sampleSize, setSampleSize, preview, sheetNames, + selectedSheetIdx, setSelectedSheetIdx, separator, setSeparator, + isExcel, excelRef, excelRange, setExcelRange, +}) => { + const sampleOptions = useSampleOptions(); + const [customizeSeparator, setCustomizeSeparator] = useState(''); + + const separatorOptions = useMemo(() => { + return [ + { key: ',', text: intl.get('dataSource.upload.separator.comma') }, + { key: '\t', text: intl.get('dataSource.upload.separator.tab') }, + { key: ';', text: intl.get('dataSource.upload.separator.semicolon') }, + { + key: '', + text: intl.get('dataSource.upload.separator.other'), + onRenderField(props, defaultRenderer) { + return ( +
+ {defaultRenderer?.(props)} + { + setCustomizeSeparator(value ?? ''); + if (value) { + setSeparator(value); + } + }} + /> +
+ ); + }, + }, + ]; + }, [customizeSeparator, setSeparator]); + + const selectedSeparatorKey = separatorOptions.find(opt => opt.key === separator)?.key ?? ''; + + return ( + + {showMoreConfig && ( + { + item && setCharset(item.key as Charset) + }} + styles={{ root: { display: 'flex', flexDirection: 'row', marginRight: '2em' }, label: { marginRight: '1em', fontWeight: 400 }, dropdown: { width: '8em' } }} + /> + )} + {!preview || preview.type.match(/^text\/.*/) ? ( + <> + {showMoreConfig && ( + { + if (option) { + setSeparator(option.key); + } + }} + /> + )} + {(!preview || preview.type === 'text/csv') && separator === ',' && ( + <> + { + if (option) { + setSampleMethod(option.key as SampleKey); + } + }} + /> + {sampleMethod === SampleKey.reservoir && ( + { + setSampleSize(Number(value)); + }} + onIncrement={() => { + setSampleSize((v) => v + 1); + }} + onDecrement={() => { + setSampleSize((v) => Math.max(v - 1, 0)); + }} + /> + )} + + )} + + ) : null} + {sheetNames && ( + ({ key: `${i}`, text: name }))} + selectedKey={`${selectedSheetIdx}`} + onChange={(_, option) => option?.key && setSelectedSheetIdx(Number(option.key))} + styles={{ root: { padding: '1em 0', display: 'flex', flexDirection: 'row', marginRight: '2em' }, label: { marginRight: '1em', fontWeight: 400 }, dropdown: { width: '10em' } }} + /> + )} + {isExcel && showMoreConfig && ( +
+ + str && setExcelRange(produce(excelRange, draft => { + draft[0][0] = Number(str); + }))} + styles={{ spinButtonWrapper: { display: 'flex', alignItems: 'center' } }} + /> + , + str && setExcelRange(produce(excelRange, draft => { + draft[0][1] = Number(str); + }))} + styles={{ spinButtonWrapper: { display: 'flex', alignItems: 'center' } }} + /> + {'-'} + str && setExcelRange(produce(excelRange, draft => { + draft[1][0] = Number(str); + }))} + styles={{ spinButtonWrapper: { display: 'flex', alignItems: 'center' } }} + /> + , + str && setExcelRange(produce(excelRange, draft => { + draft[1][1] = Number(str); + }))} + styles={{ spinButtonWrapper: { display: 'flex', alignItems: 'center' } }} + /> +
+ )} +
+ ); +}; + + +export default FileHelper; diff --git a/packages/rath-client/src/pages/dataSource/selection/file/file-upload.tsx b/packages/rath-client/src/pages/dataSource/selection/file/file-upload.tsx new file mode 100644 index 00000000..bcebeb32 --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/file/file-upload.tsx @@ -0,0 +1,346 @@ +import { ActionButton, Icon, Pivot, PivotItem, TooltipHost } from "@fluentui/react"; +import intl from 'react-intl-universal'; +import { observer } from "mobx-react-lite"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import styled from "styled-components"; +import type { loadDataFile } from "../../utils"; +import { notify } from "../../../../components/error"; +import getFileIcon from "../history/get-file-icon"; + + +const Container = styled.div` + display: flex; + margin: 1em 0; + height: 12em; + overflow: hidden; +`; + +const Input = styled.div` + flex-grow: 0; + flex-shrink: 0; + width: 12em; + height: 100%; + > input { + display: none; + } +`; + +const ActionGroup = styled.div` + border: 1px solid #8884; + border-left: none; + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + display: flex; + flex-direction: column; + overflow: hidden; + > p { + font-size: 0.9rem; + margin: 0.6em 1.2em; + color: #666; + user-select: none; + } + > div { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + display: flex; + flex-direction: column; + & [role=tab] { + margin: 0; + &, & * { + height: max-content; + min-height: unset; + line-height: 2em; + } + } + > [role=tabpanel] { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + > div { + width: 100%; + height: 100%; + overflow: auto; + } + & p { + font-size: 0.8rem; + margin: 0.6em 1.2em; + color: #555; + user-select: none; + } + } + } +`; + +const InputButton = styled.div` + border: 1px solid #115ea320; + width: 100%; + height: 100%; + cursor: pointer; + color: #115ea3a0; + display: flex; + align-items: center; + justify-content: center; + :hover { + background-color: #115ea308; + } + > * { + pointer-events: none; + user-select: none; + } +`; + +const FileOutput = styled.div` + border: 1px solid #8884; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + padding: 0 1em; + > .head { + display: flex; + flex-direction: column; + align-items: center; + padding: 1em; + overflow: hidden; + > i { + flex-grow: 0; + flex-shrink: 0; + width: 4em; + height: 4em; + margin: 0 0 0.4em; + } + > div { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + > header { + font-size: 0.9rem; + line-height: 1.2em; + font-weight: 550; + white-space: nowrap; + color: #111; + } + > span { + font-size: 0.6rem; + line-height: 1.2em; + margin: 0.6em 0; + color: #555; + } + } + } + > .action { + display: flex; + align-items: center; + justify-content: center; + > button, > button i { + font-size: 0.8rem; + } + } +`; + +const RawArea = styled.pre` + font-size: 0.8rem; + padding: 0.6em 1.2em 2em; +`; + +const PreviewArea = styled.table` + font-size: 0.8rem; + padding: 0 0 1em; + & * { + white-space: nowrap; + } + & th { + font-weight: 550; + } + & th, & td { + padding: 0.1em 1em 0.1em 0.4em; + :nth-child(even) { + background-color: #8881; + } + } + & tr:nth-child(odd) { + background-color: #8882; + } +`; + +const MAX_UPLOAD_SIZE = 1024 * 1024 * 128; + +function formatSize(size: number) { + if (size < 1024) { + return `${size}B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(2)}KB`; + } + if (size < 1024 * 1024 * 1024) { + return `${(size / 1024 / 1024).toFixed(2)}MB`; + } + return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`; +} + +export interface IFileUploadProps { + preview: File | null; + previewOfRaw: string | null; + previewOfFull: Awaited> | null; + previewOfFile: Awaited> | null; + onFileUpload: (file: File | null) => void; +} + +const FileUpload = forwardRef<{ reset: () => void }, IFileUploadProps>(function FileUpload ( + { preview, previewOfFile, previewOfFull, previewOfRaw, onFileUpload }, ref +) { + const fileInputRef = useRef(null); + + const handleReset = useCallback(() => { + onFileUpload(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + fileInputRef.current.files = null; + fileInputRef.current.click(); + } + }, [onFileUpload]); + + useImperativeHandle(ref, () => ({ + reset: () => handleReset(), + })); + + const handleButtonClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleUpload = useCallback(() => { + const [file] = fileInputRef.current?.files ?? []; + if (!file.name.endsWith('.csv') && file.size > MAX_UPLOAD_SIZE) { + notify({ + type: 'error', + title: 'Failed to upload', + content: intl.get('dataSource.advice.upload_file_too_large'), + }); + return onFileUpload(null); + } + onFileUpload(file ?? null); + }, [onFileUpload]); + + const [previewTab, setPreviewTab] = useState<'parsed' | 'full' | 'raw'>('parsed'); + + useEffect(() => { + if (previewTab === 'full' && !previewOfFull) { + setPreviewTab('parsed'); + } + }, [previewOfFull, previewTab]); + + return ( + + + + {preview ? ( + +
+ +
+
+ + {preview.name} + +
+ {formatSize(preview.size)} +
+
+
+ +
+
+ ) : ( + + + + )} + + + {preview ? ( + { + item && setPreviewTab(item.props.itemKey as typeof previewTab); + }} + > + + {previewOfFile ? ( + + + + {previewOfFile.fields.map(f => ( + + {f.name || f.fid} + + ))} + + {previewOfFile.dataSource.slice(0, 20).map((row, i) => ( + + {previewOfFile.fields.map(f => ( + + {JSON.stringify(row[f.fid])} + + ))} + + ))} + + + ) : ( +

{intl.get("dataSource.upload.data_is_empty")}

+ )} +
+ {previewOfFull && ( + + + + + {previewOfFull.fields.map(f => ( + + {f.name || f.fid} + + ))} + + {previewOfFull.dataSource.slice(0, 20).map((row, i) => ( + + {previewOfFull.fields.map(f => ( + + {JSON.stringify(row[f.fid])} + + ))} + + ))} + + + + )} + + + {previewOfRaw} + + +
+ ) : ( +

{intl.get("dataSource.upload.fileTypes")}

+ )} +
+
+ ); +}); + +export default observer(FileUpload); diff --git a/packages/rath-client/src/pages/dataSource/selection/file/index.tsx b/packages/rath-client/src/pages/dataSource/selection/file/index.tsx new file mode 100644 index 00000000..0ce8f932 --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/file/index.tsx @@ -0,0 +1,287 @@ +import { FC, useState, useRef, useCallback, useEffect } from "react"; +import { PrimaryButton, Toggle } from '@fluentui/react'; +import styled from "styled-components"; +import { observer } from "mobx-react-lite"; +import * as xlsx from 'xlsx'; +import intl from "react-intl-universal"; +import { isExcelFile, loadDataFile, loadExcelFile, loadExcelRaw, parseExcelFile, readRaw, SampleKey } from "../../utils"; +import { dataBackup, logDataImport } from "../../../../loggers/dataImport"; +import type { IMuteFieldBase, IRow } from "../../../../interfaces"; +import { DataSourceTag, IDBMeta } from "../../../../utils/storage"; +import HistoryList from "../history/history-list"; +import FileUpload from "./file-upload"; +import FileHelper, { Charset } from "./file-helper"; + + +const Container = styled.div` + max-width: 680px; + > header { + margin-top: 1.2em; + font-weight: 550; + &.upload { + display: flex; + flex-direction: row; + align-items: center; + > span { + flex-grow: 1; + flex-shrink: 1; + } + > div { + margin: 0; + flex-grow: 0; + flex-shrink: 0; + transform: scale(0.9); + > label { + padding-left: 0.3em; + margin: 0; + font-weight: 500; + } + > div { + transform: scale(0.8); + } + } + } + } + > .action { + margin: 1em 0; + } +`; + +interface FileDataProps { + onClose: () => void; + onLoadingFailed: (err: any) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: string, tag: DataSourceTag, withHistory?: IDBMeta | undefined) => void; + onDataLoading: (p: number) => void; + toggleLoadingAnimation: (on: boolean) => void; +} + +const FileData: FC = (props) => { + const { onClose, onDataLoaded, onLoadingFailed, onDataLoading, toggleLoadingAnimation } = props; + const [sampleMethod, setSampleMethod] = useState(SampleKey.none); + const [sampleSize, setSampleSize] = useState(500); + const [charset, setCharset] = useState('utf-8'); + const [separator, setSeparator] = useState(','); + const [appliedSeparator, setAppliedSeparator] = useState(separator); + + useEffect(() => { + setPreviewOfRaw(null); + setPreviewOfFile(null); + setExcelFile(false); + const delay = setTimeout(() => { + setAppliedSeparator(separator); + }, 1_000); + return () => { + clearTimeout(delay); + }; + }, [separator]); + + const [preview, setPreview] = useState(null); + const [previewOfRaw, setPreviewOfRaw] = useState(null); + const [previewOfFull, setPreviewOfFull] = useState> | null>(null); + const [previewOfFile, setPreviewOfFile] = useState> | null>(null); + + const [excelFile, setExcelFile] = useState> | false>(false); + const [selectedSheetIdx, setSelectedSheetIdx] = useState(-1); + const [excelRef, setExcelRef] = useState<[[number, number], [number, number]]>([[0, 0], [0, 0]]); + const [excelRange, setExcelRange] = useState<[[number, number], [number, number]]>([[0, 0], [0, 0]]); + + useEffect(() => { + setSelectedSheetIdx(-1); + }, [excelFile]); + + const filePreviewPendingRef = useRef>(); + + const inputRef = useRef<{ reset: () => void }>(null); + + useEffect(() => { + filePreviewPendingRef.current = undefined; + if (preview) { + setPreviewOfRaw(null); + setPreviewOfFull(null); + setPreviewOfFile(null); + setExcelFile(false); + toggleLoadingAnimation(true); + if (isExcelFile(preview)) { + const p = parseExcelFile(preview); + filePreviewPendingRef.current = p; + p.then(res => { + if (p !== filePreviewPendingRef.current) { + return; + } + setExcelFile(res); + }).catch(() => { + inputRef.current?.reset(); + }).finally(() => { + toggleLoadingAnimation(false); + }); + return; + } + const p = Promise.allSettled([ + readRaw(preview, charset, 4096, 64, 128), + loadDataFile({ + file: preview, + sampleMethod, + sampleSize, + encoding: charset, + onLoading: onDataLoading, + separator: appliedSeparator, + }), + ]); + filePreviewPendingRef.current = p; + p.then(res => { + if (p !== filePreviewPendingRef.current) { + return; + } + setPreviewOfRaw(res[0].status === 'fulfilled' ? res[0].value : null); + setPreviewOfFull(null); + setPreviewOfFile(res[1].status === 'fulfilled' ? res[1].value : null); + }).catch(reason => { + onLoadingFailed(reason); + inputRef.current?.reset(); + }).finally(() => { + toggleLoadingAnimation(false); + }); + } else { + setPreviewOfRaw(null); + setPreviewOfFull(null); + setPreviewOfFile(null); + } + }, [charset, onDataLoading, onLoadingFailed, preview, sampleMethod, sampleSize, toggleLoadingAnimation, appliedSeparator]); + + useEffect(() => { + if (excelFile && selectedSheetIdx !== -1) { + setPreviewOfRaw(null); + setPreviewOfFull(null); + setPreviewOfFile(null); + const sheet = excelFile.Sheets[excelFile.SheetNames[selectedSheetIdx]]; + const range = sheet["!ref"] ? xlsx.utils.decode_range(sheet["!ref"]) : { s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }; + const rangeRef = [[range.s.r, range.s.c], [range.e.r, range.e.c]] as [[number, number], [number, number]]; + setExcelRef(rangeRef); + setExcelRange(rangeRef); + filePreviewPendingRef.current = undefined; + toggleLoadingAnimation(true); + const p = Promise.allSettled([ + loadExcelRaw(excelFile, selectedSheetIdx, 4096, 64, 128), + loadExcelFile(excelFile, selectedSheetIdx, charset), + ] as const); + filePreviewPendingRef.current = p; + p.then(res => { + if (p !== filePreviewPendingRef.current) { + return; + } + setPreviewOfRaw(res[0].status === 'fulfilled' ? res[0].value : null); + setPreviewOfFull(res[1].status === 'fulfilled' ? res[1].value : null); + }).catch(reason => { + onLoadingFailed(reason); + inputRef.current?.reset(); + }).finally(() => { + toggleLoadingAnimation(false); + }); + } + }, [excelFile, onLoadingFailed, selectedSheetIdx, toggleLoadingAnimation, charset]); + + useEffect(() => { + if (excelFile && previewOfFull) { + setPreviewOfFile(null); + filePreviewPendingRef.current = undefined; + toggleLoadingAnimation(true); + const p = loadExcelFile(excelFile, selectedSheetIdx, charset, excelRange); + filePreviewPendingRef.current = p; + p.then(res => { + if (p !== filePreviewPendingRef.current) { + return; + } + setPreviewOfFile(res); + }).catch(reason => { + onLoadingFailed(reason); + }).finally(() => { + toggleLoadingAnimation(false); + }); + } + }, [charset, excelFile, excelRange, onLoadingFailed, previewOfFull, selectedSheetIdx, toggleLoadingAnimation]); + + const handleFileLoad = useCallback((file: File | null) => { + setPreview(file); + }, []); + + const handleFileSubmit = useCallback(() => { + if (!previewOfFile || !preview) { + return; + } + const { fields, dataSource } = previewOfFile; + logDataImport({ + dataType: 'File', + fields, + dataSource: dataSource.slice(0, 10), + size: dataSource.length + }); + dataBackup(preview); + onDataLoaded(fields, dataSource, preview.name, DataSourceTag.FILE); + onClose(); + }, [onClose, onDataLoaded, preview, previewOfFile]); + + const [showMoreConfig, setShowMoreConfig] = useState(false); + + return ( + +
+ {intl.get('dataSource.upload.new')} + setShowMoreConfig(Boolean(checked))} + /> +
+ + + {preview ? ( + previewOfFile && ( +
+ +
+ ) + ) : ( + <> +
{intl.get('dataSource.upload.history')}
+ + + )} +
+ ); +}; + +export default observer(FileData); diff --git a/packages/rath-client/src/pages/dataSource/selection/history/get-file-icon.tsx b/packages/rath-client/src/pages/dataSource/selection/history/get-file-icon.tsx new file mode 100644 index 00000000..b26bc22a --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/history/get-file-icon.tsx @@ -0,0 +1,11 @@ +import { getFileTypeIconProps, initializeFileTypeIcons } from '@fluentui/react-file-type-icons'; + + +initializeFileTypeIcons(); + +const getFileIcon = (fileName: string): string => { + const iconProps = getFileTypeIconProps({ extension: /(?<=\.).+/i.exec(fileName)?.[0], imageFileType: 'png', size: 16 }); + return iconProps.iconName; +}; + +export default getFileIcon; diff --git a/packages/rath-client/src/pages/dataSource/selection/history/history-list-item.tsx b/packages/rath-client/src/pages/dataSource/selection/history/history-list-item.tsx new file mode 100644 index 00000000..817c9bac --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/history/history-list-item.tsx @@ -0,0 +1,232 @@ +import intl from 'react-intl-universal'; +import { Icon, IconButton, TooltipHost } from "@fluentui/react"; +import { observer } from "mobx-react-lite"; +import dayjs from 'dayjs'; +import type { FC } from "react"; +import styled from "styled-components"; +import { IDBMeta, updateDataStorageUserTagGroup, UserTagGroup, userTagGroupColors } from "../../../../utils/storage"; +import { RathDemoVirtualExt } from '../demo'; +import { IDataSourceType } from '../../../../global'; +import getFileIcon from './get-file-icon'; + + +const allUserTagGroups = Object.keys(userTagGroupColors) as unknown as UserTagGroup[]; + +const UserTagGroupSize = 12; +const UserTagGroupPadding = 2; + +const ListItem = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + min-width: ${(UserTagGroupSize + UserTagGroupPadding * 2) * ((allUserTagGroups.length - 1) * 0.8 + 1) + 10}px; + height: 100%; + padding: 1.2em 1em 1em 1.4em; + border-radius: 2px; + position: relative; + box-shadow: inset 0 0 2px #8881; + > .head { + display: flex; + align-items: center; + > i { + flex-grow: 0; + flex-shrink: 0; + width: 2em; + height: 2em; + margin-right: 0.8em; + user-select: none; + } + > div { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + > header { + font-size: 0.8rem; + line-height: 1.5em; + font-weight: 550; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + color: #111; + } + > span { + font-size: 0.6rem; + line-height: 1.2em; + color: #555; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + .time { + font-size: 0.5rem; + color: #888; + } + > button { + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + font-size: 12px; + background-color: #d13438 !important; + border-radius: 50%; + color: #fff !important; + width: 1.2em; + height: 1.2em; + i { + font-weight: 1000; + line-height: 1em; + width: 1em; + height: 1em; + transform: scale(0.4); + } + opacity: 0.5; + :hover { + opacity: 1; + } + } + & .hover-only:not([aria-selected=true]) { + visibility: hidden; + } + :hover { + background-color: #8881; + & .hover-only { + visibility: visible; + } + } + cursor: pointer; +`; + +const UserTagGroupContainer = styled.div` + position: absolute; + left: 0; + top: 0; + display: flex; + flex-direction: row; + background-image: linear-gradient(to bottom, #8881, transparent 5px); + padding: 0 5px; + > svg { + margin: 0 ${UserTagGroupPadding}px; + cursor: pointer; + transition: transform 200ms; + transform: translateY(-67%); + opacity: 0.2; + :hover { + opacity: 0.95; + transform: translateY(-4px); + } + &[aria-selected=true] { + opacity: 1; + transform: translateY(-3px); + animation: pull 400ms linear forwards; + } + &[aria-selected=false] { + transition: transform 100ms; + filter: saturate(0.75); + } + > * { + pointer-events: none; + filter: drop-shadow(0.8px 1px 0.6px #888); + } + :not(:first-child) { + margin-left: ${-0.2 * UserTagGroupSize - UserTagGroupPadding}px; + } + } + @keyframes pull { + from { + transform: translateY(-4px); + } + 30% { + transform: translateY(-1.5px); + } + to { + transform: translateY(-3px); + } + } +`; + +export function formatSize(kb: number) { + if (kb < 1024) { + return `${kb}KB`; + } + if (kb < 1024 * 1024) { + return `${(kb / 1024).toFixed(2)}MB`; + } + return `${(kb / 1024 / 1024).toFixed(2)}GB`; +} + +export interface IHistoryListItemProps { + file: IDBMeta; + rowIndex: number; + colIndex: number; + handleClick?: (item: IDBMeta) => void; + handleClearClick?: (itemId: string) => void; + handleRefresh?: () => void; +} + +const HistoryListItem: FC = ({ + file, rowIndex, colIndex, handleClick, handleClearClick, handleRefresh, +}) => { + const ext = file.name.endsWith(RathDemoVirtualExt) ? RathDemoVirtualExt : /(?<=\.)[^.]+$/.exec(file.name)?.[0]; + const isRathDemo = ext === RathDemoVirtualExt; + const name = isRathDemo ? file.name.replace(new RegExp(`\\.${RathDemoVirtualExt.replaceAll(/\./g, '\\.')}$`), '') : file.name; + + return ( + handleClick?.(file)} + > +
+ +
+
+ + {name} + +
+ + {`${ext ? `${isRathDemo ? `Rath ${intl.get(`dataSource.importData.type.${IDataSourceType.DEMO}`) + }` : ext + } - ` : ''}${formatSize(file.size)}`} + +
+
+
+

{`${intl.get('dataSource.upload.lastOpen')}: ${dayjs(file.editTime).toDate().toLocaleString()}`}

+
+ e.stopPropagation()}> + {allUserTagGroups.map(key => { + const selected = file.userTagGroup === key; + return ( + { + updateDataStorageUserTagGroup(file.id, selected ? undefined : key); + handleRefresh?.(); + }} + > + + + ); + })} + + { + e.stopPropagation(); + handleClearClick?.(file.id); + }} + /> +
+ ); +}; + +export default observer(HistoryListItem); diff --git a/packages/rath-client/src/pages/dataSource/selection/history/history-list.tsx b/packages/rath-client/src/pages/dataSource/selection/history/history-list.tsx new file mode 100644 index 00000000..fa1b9caa --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/history/history-list.tsx @@ -0,0 +1,205 @@ +import intl from 'react-intl-universal'; +import { observer } from "mobx-react-lite"; +import { FC, Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import useBoundingClientRect from "../../../../hooks/use-bounding-client-rect"; +import { DataSourceTag, deleteDataStorageById, getDataStorageById, getDataStorageList, IDBMeta } from "../../../../utils/storage"; +import type { IMuteFieldBase, IRow } from '../../../../interfaces'; +import HistoryListItem from './history-list-item'; + + +const Group = styled.div` + display: flex; + flex-direction: column; + max-width: 680px; + max-height: 50vh; + overflow: hidden auto; + > * { + flex-grow: 0; + flex-shrink: 0; + max-height: unset; + } +`; + +const List = styled.div` + margin: 1em 0; + min-height: 8em; + max-height: 50vh; + max-width: 680px; + overflow: hidden auto; + display: grid; + gap: 0.4em; + grid-auto-rows: max-content; +`; + +const ITEM_MIN_WIDTH = 200; +const MAX_HISTORY_SIZE = 64; + +export enum HistoryRecentTag { + TODAY = '1d', + WEEK = '1w', + MONTH = '1mo', + THREE_MONTHS = '3mo', + SIX_MONTHS = '6mo', + YEAR = '1yr', +} + +const limitRecentTime: Record = { + [HistoryRecentTag.TODAY]: 1_000 * 60 * 60 * 24, + [HistoryRecentTag.WEEK]: 1_000 * 60 * 60 * 24 * 7, + [HistoryRecentTag.MONTH]: 1_000 * 60 * 60 * 24 * 31, + [HistoryRecentTag.THREE_MONTHS]: 1_000 * 60 * 60 * 24 * 31 * 3, + [HistoryRecentTag.SIX_MONTHS]: 1_000 * 60 * 60 * 24 * 183, + [HistoryRecentTag.YEAR]: 1_000 * 60 * 60 * 24 * 366, +}; + +const MAX_RECENT_TIME = limitRecentTime[HistoryRecentTag.YEAR]; + +export interface IHistoryListProps { + onClose: () => void; + onLoadingFailed: (err: any) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: string, tag: DataSourceTag, withHistory: IDBMeta) => void; + is?: DataSourceTag; + search?: string; + /** @default false */ + groupByPeriod?: boolean; +} + +const HistoryList: FC = ({ onDataLoaded, onClose, onLoadingFailed, is, search, groupByPeriod = false }) => { + const [localDataList, setLocalDataList] = useState([]); + const prevList = useRef(localDataList); + prevList.current = localDataList; + + const fetchDataStorageList = useCallback((sort = true) => { + getDataStorageList().then((dataList) => { + if (sort === false) { + const next = prevList.current.map(item => dataList.find(which => which.id === item.id)!).filter(Boolean); + setLocalDataList(next); + return; + } + const list = is === undefined ? dataList : dataList.filter(item => item.tag === is); + const recent = list.filter(item => item.userTagGroup !== undefined || Date.now() - item.editTime < MAX_RECENT_TIME); + const sorted = recent.sort((a, b) => b.editTime - a.editTime).sort( + (a, b) => (a.userTagGroup ?? 1023) - (b.userTagGroup ?? 1023) + ); + const tagged = sorted.filter(item => item.userTagGroup !== undefined).length; + const next = sorted.slice(0, tagged + MAX_HISTORY_SIZE); + setLocalDataList(next); + }); + }, [is]); + + useEffect(() => { + fetchDataStorageList(); + }, [fetchDataStorageList]); + + const listRef = useRef(null); + const { width } = useBoundingClientRect(listRef, { width: true }); + const colCount = useMemo(() => Math.floor((width ?? (window.innerWidth * 0.6)) / ITEM_MIN_WIDTH), [width]); + + const handleLoadHistory = useCallback((meta: IDBMeta) => { + getDataStorageById(meta.id).then(res => { + onDataLoaded(res.fields, res.dataSource, meta.name, meta.tag!, meta); + onClose(); + }).catch(onLoadingFailed); + }, [onClose, onDataLoaded, onLoadingFailed]); + + const handleDeleteHistory = useCallback((id: string) => { + deleteDataStorageById(id).then(() => { + fetchDataStorageList(); + }); + }, [fetchDataStorageList]); + + const list = useMemo(() => { + if (!search) { + return localDataList; + } + return localDataList.filter(item => { + let temp = item.name.toLocaleLowerCase(); + for (const keyword of search.toLocaleLowerCase().split(/ +/)) { + const idx = temp.indexOf(keyword); + if (idx === -1) { + return false; + } + temp = temp.slice(idx); + } + return true; + }); + }, [localDataList, search]); + + const groups = useMemo<{ list: typeof list; tag: HistoryRecentTag }[]>(() => { + if (!groupByPeriod) { + return []; + } + const all = Object.keys(limitRecentTime).map<{ list: typeof list; tag: HistoryRecentTag }>(tag => { + return { + list: [], + tag: tag as HistoryRecentTag, + }; + }); + const now = Date.now(); + for (const item of list) { + for (const group of all) { + if (group.tag === HistoryRecentTag.TODAY) { + if (new Date(item.editTime).toDateString() === new Date(now).toDateString()) { + group.list.push(item); + break; + } + } else if (now - item.editTime < limitRecentTime[group.tag]) { + group.list.push(item); + break; + } + } + } + return all.filter(group => group.list.length > 0); + }, [list, groupByPeriod]); + + return ( + + {groups.length > 0 ? groups.map(group => { + const beginTime = new Date(group.list.at(-1)!.editTime).toLocaleDateString(); + const endTime = new Date(group.list[0].editTime).toLocaleDateString(); + const period = endTime === beginTime ? endTime : `${beginTime} - ${endTime}`; + + return ( + +
+ {`${intl.get(`dataSource.upload.history_time.${group.tag}`)}${ + group.tag === HistoryRecentTag.TODAY ? '' + : ` (${period})` + }`} +
+ + {group.list.map((file, i) => ( + fetchDataStorageList(false)} + /> + ))} + +
+ ); + }) : ( + + {list.map((file, i) => ( + fetchDataStorageList(false)} + /> + ))} + + )} +
+ ); +}; + +export default observer(HistoryList); diff --git a/packages/rath-client/src/pages/dataSource/selection/history/index.tsx b/packages/rath-client/src/pages/dataSource/selection/history/index.tsx new file mode 100644 index 00000000..b29f78c2 --- /dev/null +++ b/packages/rath-client/src/pages/dataSource/selection/history/index.tsx @@ -0,0 +1,34 @@ +import { SearchBox } from "@fluentui/react"; +import { observer } from "mobx-react-lite"; +import { FC, useState } from "react"; +import HistoryList, { IHistoryListProps } from "./history-list"; + + +const HistoryPanel: FC> = ( + { onDataLoaded, onClose, onLoadingFailed } +) => { + const [search, setSearch] = useState(''); + + return ( + <> + setSearch(value ?? '')} + underlined + /> + + + ); +}; + + +export default observer(HistoryPanel); diff --git a/packages/rath-client/src/pages/dataSource/selection/index.tsx b/packages/rath-client/src/pages/dataSource/selection/index.tsx index 6a0dac72..036bf95f 100644 --- a/packages/rath-client/src/pages/dataSource/selection/index.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/index.tsx @@ -1,16 +1,16 @@ import React, { useState } from 'react'; import { Modal, ChoiceGroup, IconButton, ProgressIndicator } from '@fluentui/react'; -import { useId } from '@fluentui/react-hooks'; import intl from 'react-intl-universal'; import { IDataSourceType } from '../../../global'; import { IMuteFieldBase, IRow } from '../../../interfaces'; import { useDataSourceTypeOptions } from '../config'; import DataLoadingStatus from '../dataLoadingStatus'; +import type { DataSourceTag, IDBMeta } from '../../../utils/storage'; import FileData from './file'; import DemoData from './demo'; import RestfulData from './restful'; import OLAPData from './olap'; -import Local from './local'; +import HistoryPanel from './history'; import DatabaseData from './database/'; import AirTableSource from './airtable'; @@ -20,7 +20,7 @@ interface SelectionProps { onClose: () => void; onStartLoading: () => void; onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string, tag?: DataSourceTag | undefined, withHistory?: IDBMeta | undefined) => void; onDataLoading: (p: number) => void; setLoadingAnimation: (on: boolean) => void; } @@ -28,14 +28,12 @@ interface SelectionProps { const Selection: React.FC = props => { const { show, onClose, onDataLoaded, loading, onStartLoading, onLoadingFailed, onDataLoading, setLoadingAnimation } = props; - const [dataSourceType, setDataSourceType] = useState(IDataSourceType.DEMO); + const [dataSourceType, setDataSourceType] = useState(IDataSourceType.LOCAL); const dsTypeOptions = useDataSourceTypeOptions(); - const dsTypeLabelId = useId('dataSourceType'); - const formMap: Record = { [IDataSourceType.FILE]: ( - + ), [IDataSourceType.DEMO]: ( @@ -47,7 +45,7 @@ const Selection: React.FC = props => { ), [IDataSourceType.LOCAL]: ( - + ), [IDataSourceType.DATABASE]: ( @@ -72,7 +70,6 @@ const Selection: React.FC = props => { setDataSourceType(option.key as IDataSourceType); } }} - ariaLabelledBy={dsTypeLabelId} /> {loading && dataSourceType !== IDataSourceType.FILE && } {loading && dataSourceType === IDataSourceType.FILE && } diff --git a/packages/rath-client/src/pages/dataSource/selection/local.tsx b/packages/rath-client/src/pages/dataSource/selection/local.tsx deleted file mode 100644 index 09ab0bae..00000000 --- a/packages/rath-client/src/pages/dataSource/selection/local.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import styled from 'styled-components'; -import { IconButton } from '@fluentui/react'; -import { IMuteFieldBase, IRow } from '../../../interfaces'; -import { deleteDataStorageById, getDataStorageById, getDataStorageList, IDBMeta } from '../../../utils/storage'; - - -const LocalCont = styled.div` - .items-container{ - max-height: 500px; - overflow-y: auto; - } -` -const DataItem = styled.div` - border-radius: 3px; - border: 1px solid #ffa940; - padding: 6px; - margin: 6px 0px; - background-color: #fff7e6; - display: flex; - .desc-container{ - flex-grow: 1; - } - .button-container { - width: 120px; - display: flex; - align-items: center; - } -` - -function formatSize(size: number) { - if (size < 1024) { - return `${size}B`; - } - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(2)}KB`; - } - if (size < 1024 * 1024 * 1024) { - return `${(size / 1024 / 1024).toFixed(2)}MB`; - } - return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`; -} - -interface LocalDataProps { - onClose: () => void; - onStartLoading: () => void; - onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name?: string) => void; -} -const Local: React.FC = props => { - const { onDataLoaded, onLoadingFailed, onClose } = props; - const [localDataList, setLocalDataList] = useState([]); - useEffect(() => { - getDataStorageList().then((dataList) => { - setLocalDataList(dataList); - }) - }, []) - const totalSize = useMemo(() => { - return localDataList.reduce((acc, cur) => { - return acc + cur.size; - }, 0) - }, [localDataList]) - return -

History

-

total: {localDataList.length} datasets. {formatSize(totalSize * 1024)}

-
- { - localDataList.map(local => -
-

{local.name}

-
size: {formatSize(local.size * 1024)}
-
time: {dayjs(local.createTime).format('YYYY-MM-DD HH:mm:ss')}
-
-
- { - getDataStorageById(local.id).then(res => { - onDataLoaded(res.fields, res.dataSource, local.id); - onClose() - }).catch(onLoadingFailed) - }} - /> - { - deleteDataStorageById(local.id).then(res => { - getDataStorageList().then((dataList) => { - setLocalDataList(dataList); - }) - }) - }} - /> -
-
) - } -
-
-} - -export default Local; diff --git a/packages/rath-client/src/pages/dataSource/selection/olap.tsx b/packages/rath-client/src/pages/dataSource/selection/olap.tsx index 8e5b9119..7c727237 100644 --- a/packages/rath-client/src/pages/dataSource/selection/olap.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/olap.tsx @@ -6,6 +6,7 @@ import { IMuteFieldBase, IRow } from '../../../interfaces'; import { useGlobalStore } from '../../../store'; import { logDataImport } from '../../../loggers/dataImport'; import { notify } from '../../../components/error'; +import { DataSourceTag } from '../../../utils/storage'; const StackTokens = { childrenGap: 1 @@ -17,7 +18,7 @@ const PROTOCOL_LIST: IDropdownOption[] = [ ] interface OLAPDataProps { onClose: () => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[]) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: undefined, tag: DataSourceTag) => void; } const OLAPData: React.FC = props => { @@ -47,7 +48,7 @@ const OLAPData: React.FC = props => { dataSource: data.slice(0, 10), size: data.length }); - onDataLoaded(fieldMetas, data); + onDataLoaded(fieldMetas, data, undefined, DataSourceTag.OLAP); onClose(); }) .catch((err) => { diff --git a/packages/rath-client/src/pages/dataSource/selection/restful.tsx b/packages/rath-client/src/pages/dataSource/selection/restful.tsx index fe448be8..015352e9 100644 --- a/packages/rath-client/src/pages/dataSource/selection/restful.tsx +++ b/packages/rath-client/src/pages/dataSource/selection/restful.tsx @@ -6,6 +6,7 @@ import intl from 'react-intl-universal' import { DEMO_DATA_REQUEST_TIMEOUT } from '../../../constants'; import { IDatasetBase, IMuteFieldBase, IRow } from '../../../interfaces'; import { logDataImport } from '../../../loggers/dataImport'; +import { DataSourceTag } from '../../../utils/storage'; function requestAPIData (api: string): Promise { return new Promise((resolve, reject) => { @@ -46,7 +47,7 @@ interface RestFulProps { onClose: () => void; onStartLoading: () => void; onLoadingFailed: (err: any) => void; - onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[]) => void; + onDataLoaded: (fields: IMuteFieldBase[], dataSource: IRow[], name: undefined, tag: DataSourceTag) => void; } const RestFul: React.FC = props => { const { onClose, onStartLoading, onLoadingFailed, onDataLoaded } = props; @@ -56,7 +57,7 @@ const RestFul: React.FC = props => { onStartLoading(); requestAPIData(api).then(data => { const { dataSource, fields } = data; - onDataLoaded(fields, dataSource); + onDataLoaded(fields, dataSource, undefined, DataSourceTag.RESTFUL); logDataImport({ dataType: "Restful API", name: api, diff --git a/packages/rath-client/src/pages/dataSource/utils/index.ts b/packages/rath-client/src/pages/dataSource/utils/index.ts index 412ea28d..17da2ef3 100644 --- a/packages/rath-client/src/pages/dataSource/utils/index.ts +++ b/packages/rath-client/src/pages/dataSource/utils/index.ts @@ -1,7 +1,8 @@ import { Sampling } from 'visual-insights'; -import { FileReader } from '@kanaries/web-data-loader' +import { FileReader as KFileReader } from '@kanaries/web-data-loader' import intl from 'react-intl-universal'; import { useMemo } from "react"; +import * as xlsx from 'xlsx'; import { STORAGE_FILE_SUFFIX } from "../../../constants"; import { FileLoader } from "../../../utils"; import { IMuteFieldBase, IRow } from "../../../interfaces"; @@ -53,12 +54,35 @@ export async function transformRawDataService (rawData: IRow[]): Promise<{ } } +export const readRaw = (file: File, encoding?: string, limit?: number, rowLimit?: number, colLimit?: number): Promise => { + const fr = new FileReader(); + fr.readAsText(file, encoding); + return new Promise(resolve => { + fr.onload = () => { + let text = fr.result as string | null; + if (typeof text === 'string') { + if (limit) { + text = text.slice(0, limit); + } + if (rowLimit || colLimit) { + text = text.split('\n').slice(0, rowLimit).map(row => row.slice(0, colLimit)).join('\n'); + } + return resolve(text); + } else { + return resolve(text); + } + }; + fr.onerror = () => resolve(null); + }); +}; + interface LoadDataFileProps { file: File; sampleMethod: SampleKey; sampleSize?: number; encoding?: string; onLoading?: (progress: number) => void; + separator: string; } export async function loadDataFile(props: LoadDataFileProps): Promise<{ fields: IMuteFieldBase[]; @@ -69,17 +93,30 @@ export async function loadDataFile(props: LoadDataFileProps): Promise<{ sampleMethod, sampleSize = 500, encoding = 'utf-8', - onLoading + onLoading, + separator, } = props; /** * tmpFields is fields cat by specific rules, the results is not correct sometimes, waitting for human's input */ let rawData: IRow[] = [] - if (file.type === 'text/csv' || file.type === 'application/vnd.ms-excel') { - rawData = [] + if (file.type.match(/^text\/.*/)) { // csv-like text files + if (separator && separator !== ',') { + const content = (await readRaw(file, encoding) ?? ''); + const rows = content.split(/\r?\n/g).map(row => row.split(separator)); + const fields = rows[0]?.map((h, i) => ({ + fid: `col_${i}`, + name: h, + geoRole: '?', + analyticType: '?', + semanticType: '?', + })); + const dataSource = rows.slice(1).map(row => Object.fromEntries(fields.map((f, i) => [f.fid, row[i]]))); + return { fields, dataSource }; + } if (sampleMethod === SampleKey.reservoir) { - rawData = (await FileReader.csvReader({ + rawData = (await KFileReader.csvReader({ file, encoding, config: { @@ -89,7 +126,7 @@ export async function loadDataFile(props: LoadDataFileProps): Promise<{ onLoading })) as IRow[] } else { - rawData = (await FileReader.csvReader({ + rawData = (await KFileReader.csvReader({ file, encoding, onLoading @@ -100,6 +137,19 @@ export async function loadDataFile(props: LoadDataFileProps): Promise<{ if (sampleMethod === SampleKey.reservoir) { rawData = Sampling.reservoirSampling(rawData, sampleSize) } + } else if (file.type.startsWith('text/')) { + let content = (await readRaw(file, encoding) ?? ''); + if (separator && separator !== ',') { + content = content.replaceAll( + new RegExp(`\\\\{0, 2}${separator}`, 'g'), + part => `${new RegExp(`^(\\{2})?`).exec(part)?.[0] ?? ''},`, + ); + } + const translatedFile = new File([new Blob([content], { type: 'text/plain' })], 'file.csv'); + rawData = (await KFileReader.csvReader({ + file: translatedFile, + encoding, + })) as IRow[]; } else { throw new Error(`unsupported file type=${file.type} `) } @@ -107,6 +157,60 @@ export async function loadDataFile(props: LoadDataFileProps): Promise<{ return dataset } +export const isExcelFile = (file: File): boolean => { + return [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx + 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', // xlsb + 'application/vnd.ms-excel', // xls + 'application/vnd.ms-excel.sheet.macroEnabled.12', // xlsm + ].includes(file.type); +}; + +export const parseExcelFile = async (file: File) => { + const content = await FileLoader.binaryLoader(file); + const data = xlsx.read(content); + return data; +}; + +export const loadExcelRaw = async (data: Awaited>, sheetIdx: number, limit?: number, rowLimit?: number, colLimit?: number): Promise => { + const sheet = data.SheetNames[sheetIdx]; + const worksheet = data.Sheets[sheet]; + const csvData = xlsx.utils.sheet_to_csv(worksheet, { skipHidden: true }); // more options available here + let text = csvData; + if (limit) { + text = text.slice(0, limit); + } + if (rowLimit || colLimit) { + text = text.split('\n').slice(0, rowLimit).map(row => row.slice(0, colLimit)).join('\n'); + } + return text; +}; + +export const loadExcelFile = async ( + data: Awaited>, sheetIdx: number, encoding: string, + range?: [[number, number], [number, number]], +): Promise<{ + fields: IMuteFieldBase[]; + dataSource: IRow[]; +}> => { + const sheet = data.SheetNames[sheetIdx]; + const worksheet = data.Sheets[sheet]; + const copy = range ? { ...worksheet } : worksheet; + if (range) { + copy['!ref'] = xlsx.utils.encode_range({ + s: { r: range[0][0], c: range[0][1] }, + e: { r: range[1][0], c: range[1][1] }, + }); + } + const csvData = xlsx.utils.sheet_to_csv(copy, { skipHidden: true }); // more options available here + const csvFile = new File([new Blob([csvData], { type: 'text/plain' })], 'file.csv'); + const rawData = (await KFileReader.csvReader({ + file: csvFile, + encoding, + })) as IRow[]; + return await transformRawDataService(rawData); +}; + export async function loadRathStorageFile (file: File): Promise { // FIXME file type if (file.name.split('.').slice(-1)[0] === STORAGE_FILE_SUFFIX) { diff --git a/packages/rath-client/src/pages/loginInfo/account.tsx b/packages/rath-client/src/pages/loginInfo/account.tsx index 57f1a029..23306643 100644 --- a/packages/rath-client/src/pages/loginInfo/account.tsx +++ b/packages/rath-client/src/pages/loginInfo/account.tsx @@ -13,10 +13,8 @@ const AccountDiv = styled.div` width: 100%; display: flex; flex-direction: column; - align-items: center; margin-bottom: 20px; - padding-left: 10px; - padding-top: 10px; + padding-left: 2em; .label { font-weight: 600; font-size: 14px; @@ -26,20 +24,14 @@ const AccountDiv = styled.div` -webkit-font-smoothing: antialiased; } .account { + display: flex; + flex-direction: column; width: 100%; - > span { - width: 100%; + > .label { + margin-bottom: 1em; } - > span:first-child { - display: flex; - justify-content: space-between; - height: 35px; - line-height: 35px; - margin-bottom: 3px; - } - > span:last-child { - height: 35px; - line-height: 35px; + > button { + width: max-content; } } .phone { @@ -100,23 +92,20 @@ function Account() { ) : (
- - Account - {userName ? ( - { - commonStore.commitLogout() - }} - > - {intl.get('login.signOut')} - - ) : ( - [setIsLoginStatus(true)]}> - {intl.get('login.signIn')} - - )} - + Account + {userName ? ( + { + commonStore.commitLogout() + }} + > + {intl.get('login.signOut')} + + ) : ( + [setIsLoginStatus(true)]}> + {intl.get('login.signIn')} + + )} {userName && }
{userName && ( diff --git a/packages/rath-client/src/pages/loginInfo/index.tsx b/packages/rath-client/src/pages/loginInfo/index.tsx index 5b99c641..2284276a 100644 --- a/packages/rath-client/src/pages/loginInfo/index.tsx +++ b/packages/rath-client/src/pages/loginInfo/index.tsx @@ -1,22 +1,42 @@ -import { useState } from 'react'; +import { FC, useState } from 'react'; import intl from 'react-intl-universal'; import { observer } from 'mobx-react-lite'; -import { Dialog, Icon } from '@fluentui/react'; +import { Dialog, Icon, Pivot, PivotItem } from '@fluentui/react'; import styled from 'styled-components'; -import { PreferencesListType } from '../../App'; import { useGlobalStore } from '../../store'; -import LoginInfoList from './loginInfo'; -interface loginInfoProps { - preferencesList: PreferencesListType[]; +import Account from './account'; +import Setup from './setup'; + + +export enum PreferencesType { + Account = 'account', + Info = 'info', + Setting = 'setting', + Header = 'header' +} +export interface PreferencesListType { + key: PreferencesType; + name: PreferencesType; + icon: string; element: () => JSX.Element; } +const preferencesList: PreferencesListType[] = [ + { key: PreferencesType.Account, name: PreferencesType.Account, icon: 'Home', element: () => }, + // { key: PreferencesType.Info, name: PreferencesType.Info, icon: 'Info', element: () => }, + // { key: PreferencesType.Header, name: PreferencesType.Header, icon: 'Contact', element: () =>
}, + { key: PreferencesType.Setting, name: PreferencesType.Setting, icon: 'Settings', element: () => }, +]; + const LoginInfoDiv = styled.div` height: 100%; display: flex; flex-direction: column; - > div:first-child { - flex: 1; + border-top-width: 1px; + padding: 0.6em 0.8em 0.8em; + > div { + user-select: none; + cursor: pointer; } .user { white-space: nowrap; @@ -24,7 +44,7 @@ const LoginInfoDiv = styled.div` overflow-x: auto; font-size: 0.875rem; line-height: 1.25rem; - font-weight: 500; + font-weight: 400; } .user::-webkit-scrollbar { display: none; @@ -33,14 +53,6 @@ const LoginInfoDiv = styled.div` display: flex; align-items: center; } - .hidden-login { - /* flex flex-shrink-0 border-t border-indigo-800 p-4 bg-gray-700 cursor-pointer */ - display: flex; - flex-shrink: 0; - border-top-width: 1px; - padding: 1rem; - cursor: pointer; - } .user-name { /* ml-2 */ margin-left: 0.5rem; @@ -49,19 +61,50 @@ const LoginInfoDiv = styled.div` } `; -const LoginInfo = (props: loginInfoProps) => { - const { preferencesList, element } = props; +const Container = styled.div` + & .content { + display: flex; + flex-direction: row; + > [role=tablist] { + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid #8888; + > [role=tab] { + margin: 0px 4px 8px 0; + ::before { + right: 0px; + width: 2px; + height: unset; + top: 2px; + bottom: 2px; + left: unset; + transition: unset; + } + } + } + > [role=tabpanel] { + flex-grow: 1; + flex-shrink: 1; + } + } +`; + +const LoginInfo: FC = () => { const { commonStore } = useGlobalStore(); const { userName, navMode, avatarUrl, info } = commonStore; const [loginHidden, setLoginHidden] = useState(true); + const [tab, setTab] = useState(PreferencesType.Account); + return ( -
{element()}
{ setLoginHidden(false); }} + role="button" + aria-haspopup > { dialogContentProps={{ title: intl.get('login.preferences') }} minWidth={550} > - + + item && setTab(item.props.itemKey as typeof tab)}> + {preferencesList.map(pref => ( + + {pref.element()} + + ))} + +
diff --git a/packages/rath-client/src/pages/loginInfo/loginInfo.tsx b/packages/rath-client/src/pages/loginInfo/loginInfo.tsx index 3afb7ecf..d24254da 100644 --- a/packages/rath-client/src/pages/loginInfo/loginInfo.tsx +++ b/packages/rath-client/src/pages/loginInfo/loginInfo.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Icon } from '@fluentui/react'; import styled from 'styled-components'; import intl from 'react-intl-universal'; -import { PreferencesListType, PreferencesType } from '../../App'; +import { PreferencesListType, PreferencesType } from '.'; const LoginInfoListDiv = styled.div` height: 250px; diff --git a/packages/rath-client/src/store/commonStore.ts b/packages/rath-client/src/store/commonStore.ts index 15366a07..3dd41f5c 100644 --- a/packages/rath-client/src/store/commonStore.ts +++ b/packages/rath-client/src/store/commonStore.ts @@ -2,7 +2,7 @@ import { makeAutoObservable, observable, runInAction } from 'mobx'; import { Specification } from 'visual-insights'; import { COMPUTATION_ENGINE, EXPLORE_MODE, PIVOT_KEYS } from '../constants'; import { IAccessPageKeys, ITaskTestMode, IVegaSubset } from '../interfaces'; -import { getAvatarURL, getServerUrl, AVATAR_IMG_LIST, IAVATAR_TYPES } from '../utils/user'; +import { getAvatarURL, getMainServerUrl, AVATAR_IMG_LIST, IAVATAR_TYPES } from '../utils/user'; import { destroyRathWorker, initRathWorker, rathEngineService } from '../services/index'; import { transVegaSubset2Schema } from '../utils/transform'; import { notify } from '../components/error'; @@ -159,7 +159,7 @@ export class CommonStore { } public async liteAuth(certMethod: 'email' | 'phone') { - const url = getServerUrl('/api/liteAuth'); + const url = getMainServerUrl('/api/liteAuth'); const { certCode, phone, email } = this.signup; const res = await fetch(url, { method: 'POST', @@ -212,7 +212,7 @@ export class CommonStore { public async commitLogout() { try { - const url = getServerUrl('/api/logout'); + const url = getMainServerUrl('/api/logout'); const res = await fetch(url, { method: 'GET', }); @@ -237,7 +237,7 @@ export class CommonStore { public async updateAuthStatus() { try { - const url = getServerUrl('/api/loginStatus'); + const url = getMainServerUrl('/api/loginStatus'); const res = await request.get<{}, { loginStatus: boolean; userName: string }>(url); if (res.loginStatus && res.userName !== null) { runInAction(() => { @@ -254,7 +254,7 @@ export class CommonStore { } } public async getPersonalInfo() { - const url = getServerUrl('/api/ce/personal'); + const url = getMainServerUrl('/api/ce/personal'); try { const result = await request.get<{}, IUserInfo>(url); if (result !== null) { @@ -279,7 +279,7 @@ export class CommonStore { const { file } = value; const data = new FormData(); file && data.append('file', file); - const url = getServerUrl('/api/ce/avatar'); + const url = getMainServerUrl('/api/ce/avatar'); const res = await fetch(url, { method: 'POST', credentials: 'include', @@ -300,7 +300,7 @@ export class CommonStore { } public async getAvatarImgUrl() { - const url = getServerUrl('/api/ce/avatar'); + const url = getMainServerUrl('/api/ce/avatar'); try { const result = await request.get<{}, { avatarURL: string }>(url); if (result !== null) { diff --git a/packages/rath-client/src/store/fetch.ts b/packages/rath-client/src/store/fetch.ts index d88c3853..8368300e 100644 --- a/packages/rath-client/src/store/fetch.ts +++ b/packages/rath-client/src/store/fetch.ts @@ -1,8 +1,8 @@ -import { getServerUrl } from '../utils/user'; +import { getMainServerUrl } from '../utils/user'; import { ILoginForm } from './commonStore'; export async function commitLoginService(props: ILoginForm) { - const url = getServerUrl('/api/login'); + const url = getMainServerUrl('/api/login'); const res = await fetch(url, { method: 'POST', headers: { diff --git a/packages/rath-client/src/utils/fileParser.ts b/packages/rath-client/src/utils/fileParser.ts index 419b8055..ce37fb2d 100644 --- a/packages/rath-client/src/utils/fileParser.ts +++ b/packages/rath-client/src/utils/fileParser.ts @@ -34,4 +34,19 @@ export function textLoader (file: File): Promise { } reader.onerror = reject }) +} + +export function binaryLoader (file: File): Promise { + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.readAsArrayBuffer(file) + reader.onload = (ev) => { + if (ev.target) { + resolve(ev.target.result as ArrayBuffer) + } else { + reject(ev) + } + } + reader.onerror = reject + }) } \ No newline at end of file diff --git a/packages/rath-client/src/utils/storage.ts b/packages/rath-client/src/utils/storage.ts index fe18d9f9..ddab8b4a 100644 --- a/packages/rath-client/src/utils/storage.ts +++ b/packages/rath-client/src/utils/storage.ts @@ -5,15 +5,50 @@ import type { IFieldMeta, IMuteFieldBase, IRow } from '../interfaces'; import type { ICausalStoreSave } from '../store/causalStore/mainStore'; import type { CausalLinkDirection } from './resolve-causal'; + +export enum DataSourceTag { + FILE = '@file', + DEMO = '@demo', + RESTFUL = '@restful', + DATABASE = '@database', + OLAP = '@olap', + AIR_TABLE = '@air_table', +} + +export enum UserTagGroup { + Red, + Green, + Yellow, + Berry, + Peach, + DarkGreen, + Teal, + Navy, +} + +export const userTagGroupColors: Record = { + [UserTagGroup.Red]: '#d13438', + [UserTagGroup.Green]: '#13a10e', + [UserTagGroup.Yellow]: '#fde300', + [UserTagGroup.Berry]: '#c239b3', + [UserTagGroup.Peach]: '#ff8c00', + [UserTagGroup.DarkGreen]: '#063b06', + [UserTagGroup.Teal]: '#00b7c3', + [UserTagGroup.Navy]: '#0027b4', +}; + export interface IDBMeta { id: string; name: string; type: 'workspace' | 'dataset'; createTime: number; editTime: number; + /** kb */ size: number; rows?: number; fields?: IMuteFieldBase[]; + tag?: DataSourceTag; + userTagGroup?: UserTagGroup | undefined; } /** @deprecated */ @@ -100,14 +135,14 @@ export async function deleteStorageByIdInLocal (id: string) { await storages.removeItem(id) } -export async function getDataStorageList (): Promise { +export async function getDataStorageList (): Promise<(IDBMeta & { type: 'dataset' })[]> { const metas = localforage.createInstance({ name: STORAGE_INSTANCE, storeName: STORAGES.META }); const keys = await metas.keys(); const values = await Promise.all(keys.map(itemKey => metas.getItem(itemKey))) as IDBMeta[]; - return values.filter(v => v.type === 'dataset'); + return values.filter(v => v.type === 'dataset') as (IDBMeta & { type: 'dataset' })[]; } export async function getDataStorageById (id: string): Promise<{ fields: IMuteFieldBase[]; dataSource: IRow[] }> { @@ -140,7 +175,9 @@ export async function deleteDataStorageById (id: string) { await storages.removeItem(id) } -export async function setDataStorage(name: string, fields: IMuteFieldBase[], dataSource: IRow[]) { +export async function setDataStorage( + name: string, fields: IMuteFieldBase[], dataSource: IRow[], tag: DataSourceTag, withHistory?: IDBMeta, +) { const time = Date.now(); const dataString = JSON.stringify(dataSource); const metas = localforage.createInstance({ @@ -151,11 +188,12 @@ export async function setDataStorage(name: string, fields: IMuteFieldBase[], dat id: name, name, type: 'dataset', - createTime: time, + createTime: withHistory?.createTime ?? time, editTime: time, size: Math.round(dataString.length / 1024), rows: dataSource.length, - fields + fields, + tag: withHistory?.tag ?? tag, } as IDBMeta) const storages = localforage.createInstance({ name: STORAGE_INSTANCE, @@ -177,6 +215,18 @@ export async function updateDataStorageMeta(name: string, fields: IMuteFieldBase } as IDBMeta) } +export async function updateDataStorageUserTagGroup(name: string, userTagGroup: UserTagGroup | undefined) { + const metas = localforage.createInstance({ + name: STORAGE_INSTANCE, + storeName: STORAGES.META + }); + const oldMeta = await metas.getItem(name) as IDBMeta; + await metas.setItem(name, { + ...oldMeta, + userTagGroup, + } as IDBMeta) +} + export async function updateDataConfig(name: string, value: any) { const metas = localforage.createInstance({ name: STORAGE_INSTANCE, diff --git a/packages/rath-client/src/utils/user.ts b/packages/rath-client/src/utils/user.ts index 50e0144f..c8a200d7 100644 --- a/packages/rath-client/src/utils/user.ts +++ b/packages/rath-client/src/utils/user.ts @@ -1,3 +1,5 @@ +import { RathEnv, RATH_ENV } from "../constants"; + export enum IAVATAR_TYPES { gravatar = 'gravatar', default = 'default' @@ -16,12 +18,16 @@ export const AVATAR_IMG_LIST: string[] = new Array(18) export const DEFAULT_AVATAR_URL_PREFIX = 'https://foghorn-assets.s3.ap-northeast-1.amazonaws.com/avatar/'; -export function getServerUrl(path: string) { +const DEFAULT_MAIN_SERVER_HOST = `${ + window.location.host.match(/kanaries\.[a-z]+$/i)?.[0] ?? 'kanaries.net' +}`; + +export function getMainServerUrl(path: string) { const baseURL = new URL(window.location.href); - const DATA_SERVER_URL = - baseURL.searchParams.get('main_service') || localStorage.getItem('main_service') || window.location.href; - // const devSpecURL = new URL(w|| window.location.href) - const url = new URL(DATA_SERVER_URL); + const CONFIGURABLE_MAIN_SERVER_URL = RathEnv === RATH_ENV.ONLINE ? null + : baseURL.searchParams.get('main_service') || localStorage.getItem('main_service'); + const serverUrl = CONFIGURABLE_MAIN_SERVER_URL ?? `${baseURL.protocol}//${DEFAULT_MAIN_SERVER_HOST}`; + const url = new URL(serverUrl); url.pathname = path; return url.toString(); } diff --git a/yarn.lock b/yarn.lock index 6e5242b8..bb75d29a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1812,6 +1812,14 @@ "@fluentui/set-version" "^8.2.2" tslib "^2.1.0" +"@fluentui/dom-utilities@^2.2.3": + version "2.2.3" + resolved "https://registry.npmmirror.com/@fluentui/dom-utilities/-/dom-utilities-2.2.3.tgz#0dd8b1a0ba4d75232b7fea4552bd80afbf120593" + integrity sha512-Ml/xwpTC6vb9lHHVAbSUD9jMbt9nVzV208D0FEoQn0c0+dP2vdMXSvXC/QHs/57B6JicttVQPuX6EcPwR3Mkpg== + dependencies: + "@fluentui/set-version" "^8.2.3" + tslib "^2.1.0" + "@fluentui/font-icons-mdl2@^8.4.13", "@fluentui/font-icons-mdl2@^8.5.0": version "8.5.0" resolved "https://registry.yarnpkg.com/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.0.tgz#b358ed57be5b52c921558be6535e70b68d4d4f1e" @@ -1848,6 +1856,23 @@ "@fluentui/set-version" "^8.2.2" tslib "^2.1.0" +"@fluentui/merge-styles@^8.5.4": + version "8.5.4" + resolved "https://registry.npmmirror.com/@fluentui/merge-styles/-/merge-styles-8.5.4.tgz#e2c487ccc6d5b8e5a9c8914a148bb55146a54c33" + integrity sha512-CeQIEcEgZu0cxqqyhJyTqySXoUL1vXfdWDJ8WMzchaNnhvOvoXISw8xXHpNXUtEn+HgPrcy9mHQwFcAc+jv3Wg== + dependencies: + "@fluentui/set-version" "^8.2.3" + tslib "^2.1.0" + +"@fluentui/react-file-type-icons@^8.8.3": + version "8.8.3" + resolved "https://registry.npmmirror.com/@fluentui/react-file-type-icons/-/react-file-type-icons-8.8.3.tgz#bf55073acc88e83f7397fd0500051e8a25ccf454" + integrity sha512-Mn8qs+5e9shop7s3hQ0CfqR6XNXVOqYsqAzQH73ZEJpgN9B5MrQ5NECYKgJei8BEbOQatD8ZCDLTjv/Bcbm/6Q== + dependencies: + "@fluentui/set-version" "^8.2.3" + "@fluentui/style-utilities" "^8.8.3" + tslib "^2.1.0" + "@fluentui/react-focus@^8.8.5": version "8.8.5" resolved "https://registry.yarnpkg.com/@fluentui/react-focus/-/react-focus-8.8.5.tgz#6003ddadc914a2eaadb9d68d5feb793e6dc09a95" @@ -1912,6 +1937,13 @@ dependencies: tslib "^2.1.0" +"@fluentui/set-version@^8.2.3": + version "8.2.3" + resolved "https://registry.npmmirror.com/@fluentui/set-version/-/set-version-8.2.3.tgz#fe9761353dbea6a02f4a140f9bc67e5dd07ca22d" + integrity sha512-/+5vrI1Bq/ZsNDEK9++RClnDOeCILS8RXxZb7OAqmOc8GnPScxKcIN8e/1bosUxTjb2EB1KbVk6XeUpk0WvQIg== + dependencies: + tslib "^2.1.0" + "@fluentui/style-utilities@^8.7.12": version "8.7.12" resolved "https://registry.yarnpkg.com/@fluentui/style-utilities/-/style-utilities-8.7.12.tgz#f8ae3c2b850f38705c0556569a0d15d2ba4e6363" @@ -1924,6 +1956,18 @@ "@microsoft/load-themed-styles" "^1.10.26" tslib "^2.1.0" +"@fluentui/style-utilities@^8.8.3": + version "8.8.3" + resolved "https://registry.npmmirror.com/@fluentui/style-utilities/-/style-utilities-8.8.3.tgz#6ab8e332482e9698a637036f438f67e13878da1a" + integrity sha512-DxcCIHnKdaBpzdIawZIMcVyn8xVbK6A37J0Q7MPdjb9VV4Nsp/ohWnM9nPrPlbB+RSsKWrIssgWJdn5yZM9Wxg== + dependencies: + "@fluentui/merge-styles" "^8.5.4" + "@fluentui/set-version" "^8.2.3" + "@fluentui/theme" "^2.6.19" + "@fluentui/utilities" "^8.13.4" + "@microsoft/load-themed-styles" "^1.10.26" + tslib "^2.1.0" + "@fluentui/theme@^2.6.16": version "2.6.16" resolved "https://registry.yarnpkg.com/@fluentui/theme/-/theme-2.6.16.tgz#a29c58bf16f765ba1b97a497e572fd1fdc469c44" @@ -1934,6 +1978,16 @@ "@fluentui/utilities" "^8.13.1" tslib "^2.1.0" +"@fluentui/theme@^2.6.19": + version "2.6.19" + resolved "https://registry.npmmirror.com/@fluentui/theme/-/theme-2.6.19.tgz#1a8254452eefcb7322ae3a5d60b466b32b30c7fb" + integrity sha512-Pk4STq3WAM3Mq4fGCBrq3F43o1u2SitO7CZ6A3/ALreaxTA1LC4bbyKQTYH3tvxapqEOYaEPYZ3dFjWjqYlFfg== + dependencies: + "@fluentui/merge-styles" "^8.5.4" + "@fluentui/set-version" "^8.2.3" + "@fluentui/utilities" "^8.13.4" + tslib "^2.1.0" + "@fluentui/utilities@^8.13.1": version "8.13.1" resolved "https://registry.yarnpkg.com/@fluentui/utilities/-/utilities-8.13.1.tgz#028fd2e93f68b5f386039970dce0ae642eb7e9da" @@ -1944,6 +1998,16 @@ "@fluentui/set-version" "^8.2.2" tslib "^2.1.0" +"@fluentui/utilities@^8.13.4": + version "8.13.4" + resolved "https://registry.npmmirror.com/@fluentui/utilities/-/utilities-8.13.4.tgz#bad157358f2dcb09acd005a1921f7cb3abfa03c6" + integrity sha512-oJ6q8BvVdr0eEG5RgI/VBtKX2JvJV2h0AUkR7FwZoT8fvUUH/iykPZO/5CAVcQDyiXj73hmBibiEGkWNFFuPfw== + dependencies: + "@fluentui/dom-utilities" "^2.2.3" + "@fluentui/merge-styles" "^8.5.4" + "@fluentui/set-version" "^8.2.3" + tslib "^2.1.0" + "@formatjs/intl-unified-numberformat@^3.2.0": version "3.3.7" resolved "https://registry.yarnpkg.com/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.3.7.tgz#9995a24568908188e716d81a1de5b702b2ee00e2" @@ -3388,7 +3452,7 @@ "@types/react-dom@^17.0.1", "@types/react-dom@^17.x": version "17.0.18" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.18.tgz#8f7af38f5d9b42f79162eea7492e5a1caff70dc2" + resolved "https://registry.npmmirror.com/@types/react-dom/-/react-dom-17.0.18.tgz#8f7af38f5d9b42f79162eea7492e5a1caff70dc2" integrity sha512-rLVtIfbwyur2iFKykP2w0pl/1unw26b5td16d5xMgp7/yjTHomkyxPYChFoCr/FtEX1lN9wY6lFj1qvKdS5kDw== dependencies: "@types/react" "^17" @@ -3837,6 +3901,11 @@ adjust-sourcemap-loader@^4.0.0: loader-utils "^2.0.0" regex-parser "^2.2.11" +adler-32@~1.3.0: + version "1.3.1" + resolved "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2" + integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4564,6 +4633,14 @@ case-sensitive-paths-webpack-plugin@^2.4.0: resolved "https://registry.npmmirror.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw== +cfb@~1.2.1: + version "1.2.2" + resolved "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44" + integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA== + dependencies: + adler-32 "~1.3.0" + crc-32 "~1.2.0" + chalk@^1.0.0, chalk@^1.1.3: version "1.1.3" resolved "https://registry.npmmirror.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -4736,6 +4813,11 @@ codemirror@^6.0.1: "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.0.0" +codepage@~1.15.0: + version "1.15.0" + resolved "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab" + integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA== + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -4977,6 +5059,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +crc-32@~1.2.0, crc-32@~1.2.1: + version "1.2.2" + resolved "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -6612,6 +6699,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +frac@~1.1.2: + version "1.1.2" + resolved "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b" + integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA== + fraction.js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" @@ -11647,6 +11739,13 @@ sqlstring@^2.3.2: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== +ssf@~0.11.2: + version "0.11.2" + resolved "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c" + integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g== + dependencies: + frac "~1.1.2" + stable@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" @@ -13239,11 +13338,21 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +wmf@~1.0.1: + version "1.0.2" + resolved "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da" + integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw== + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +word@~0.3.0: + version "0.3.0" + resolved "https://registry.npmmirror.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961" + integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA== + workbox-background-sync@6.5.4: version "6.5.4" resolved "https://registry.npmmirror.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9" @@ -13471,6 +13580,19 @@ ws@^8.4.2: resolved "https://registry.npmmirror.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== +xlsx@^0.18.5: + version "0.18.5" + resolved "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0" + integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ== + dependencies: + adler-32 "~1.3.0" + cfb "~1.2.1" + codepage "~1.15.0" + crc-32 "~1.2.1" + ssf "~0.11.2" + wmf "~1.0.1" + word "~0.3.0" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"