From 892da55b289bf0ef8570ba686dbd6d2f52bbd8c9 Mon Sep 17 00:00:00 2001 From: waynelwz <100347187+waynelwz@users.noreply.github.com> Date: Tue, 18 Jul 2023 15:43:52 +0800 Subject: [PATCH] feat(console): dataset upload (#2507) --- console/jest.config.ts | 7 +- console/package.json | 3 +- .../src/IconFont/fonts/iconfont.css | 78 +- .../src/IconFont/fonts/iconfont.js | 2 +- .../src/IconFont/fonts/iconfont.json | 128 ++- .../src/IconFont/fonts/iconfont.ttf | 4 +- .../src/IconFont/fonts/iconfont.woff | 4 +- .../src/IconFont/fonts/iconfont.woff2 | 4 +- .../starwhale-ui/src/IconFont/index.tsx | 5 + .../starwhale-ui/src/Upload/DraggerUpload.tsx | 400 +++++++ .../starwhale-ui/src/Upload/UploadItem.tsx | 93 ++ .../starwhale-ui/src/Upload/hooks/useSign.ts | 28 + .../src/Upload/hooks/useUploadingControl.ts | 64 ++ .../starwhale-ui/src/Upload/index.tsx | 1 + .../src/Upload/react-dropzone/.eslintrc | 37 + .../src/Upload/react-dropzone/index.jsx | 988 ++++++++++++++++++ .../src/Upload/react-dropzone/utils/index.js | 322 ++++++ .../packages/starwhale-ui/src/Upload/types.ts | 8 + .../packages/starwhale-ui/src/Upload/utils.ts | 73 ++ console/src/api/const.ts | 1 + console/src/components/Form/form.tsx | 4 +- console/src/components/Form/index.module.scss | 11 + console/src/components/Shared.tsx | 2 +- console/src/domain/base/services/filestore.ts | 30 + .../domain/dataset/components/DatasetForm.tsx | 167 ++- .../src/domain/dataset/schemas/dataset.tsx | 35 +- .../src/domain/dataset/services/dataset.ts | 47 +- console/src/domain/user/components/User.tsx | 4 +- console/src/hooks/useCurrentUser.ts | 1 + console/src/i18n/locales.ts | 72 ++ .../src/pages/Dataset/DatasetBuildList.tsx | 121 +++ .../pages/Dataset/DatasetBuildListCard.tsx | 90 ++ console/src/pages/Dataset/DatasetListCard.tsx | 185 ++-- console/src/pages/Project/DatasetNewCard.tsx | 42 + console/src/pages/Project/ProjectSidebar.tsx | 2 +- console/src/routes.tsx | 16 + console/yarn.lock | 631 ++++++++++- 37 files changed, 3538 insertions(+), 172 deletions(-) create mode 100644 console/packages/starwhale-ui/src/Upload/DraggerUpload.tsx create mode 100644 console/packages/starwhale-ui/src/Upload/UploadItem.tsx create mode 100644 console/packages/starwhale-ui/src/Upload/hooks/useSign.ts create mode 100644 console/packages/starwhale-ui/src/Upload/hooks/useUploadingControl.ts create mode 100644 console/packages/starwhale-ui/src/Upload/index.tsx create mode 100644 console/packages/starwhale-ui/src/Upload/react-dropzone/.eslintrc create mode 100755 console/packages/starwhale-ui/src/Upload/react-dropzone/index.jsx create mode 100644 console/packages/starwhale-ui/src/Upload/react-dropzone/utils/index.js create mode 100644 console/packages/starwhale-ui/src/Upload/types.ts create mode 100644 console/packages/starwhale-ui/src/Upload/utils.ts create mode 100644 console/src/domain/base/services/filestore.ts create mode 100644 console/src/pages/Dataset/DatasetBuildList.tsx create mode 100644 console/src/pages/Dataset/DatasetBuildListCard.tsx create mode 100644 console/src/pages/Project/DatasetNewCard.tsx diff --git a/console/jest.config.ts b/console/jest.config.ts index 3927b23e0a..0008dce974 100644 --- a/console/jest.config.ts +++ b/console/jest.config.ts @@ -26,7 +26,12 @@ export default { coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection - coveragePathIgnorePatterns: ['/node_modules/', '/src/_generated_', '/src/stories'], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/src/_generated_', + '/src/stories', + '/packages/starwhale-ui/src/Upload/react-dropzone', + ], // Indicates which provider should be used to instrument code for coverage // coverageProvider: "babel", diff --git a/console/package.json b/console/package.json index ad558a592e..b899e5f3c7 100644 --- a/console/package.json +++ b/console/package.json @@ -82,6 +82,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^17.0.2", + "react-dropzone": "^14.2.3", "react-grid-layout": "^1.3.4", "react-hooks-global-state": "^1.0.1", "react-i18next": "^11.11.4", @@ -270,4 +271,4 @@ "framer-motion": "4.1.17", "react-virtualized": "git+https://git@github.com/remorses/react-virtualized-fixed-import.git#9.22.3" } -} +} \ No newline at end of file diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.css b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.css index 95bb4484a8..fb178fee3e 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.css +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.css @@ -1,8 +1,8 @@ @font-face { font-family: "iconfont"; /* Project id 3410006 */ - src: url('iconfont.woff2?t=1688627096806') format('woff2'), - url('iconfont.woff?t=1688627096806') format('woff'), - url('iconfont.ttf?t=1688627096806') format('truetype'); + src: url('iconfont.woff2?t=1689660767619') format('woff2'), + url('iconfont.woff?t=1689660767619') format('woff'), + url('iconfont.ttf?t=1689660767619') format('truetype'); } .iconfont { @@ -13,10 +13,78 @@ -moz-osx-font-smoothing: grayscale; } +.icon-a-BuildImage:before { + content: "\e674"; +} + +.icon-Cancel:before { + content: "\e675"; +} + +.icon-Restore:before { + content: "\e676"; +} + +.icon-a-Pushlocal:before { + content: "\e677"; +} + +.icon-a-SecuritySettings:before { + content: "\e678"; +} + +.icon-a-onlineevaluation:before { + content: "\e679"; +} + +.icon-fold21:before { + content: "\e67a"; +} + +.icon-Rerun:before { + content: "\e67b"; +} + +.icon-unfold21:before { + content: "\e67c"; +} + +.icon-a-ViewTasks:before { + content: "\e67d"; +} + +.icon-a-ImageBuilt:before { + content: "\e67e"; +} + +.icon-link:before { + content: "\e680"; +} + +.icon-a-runmodel:before { + content: "\e681"; +} + +.icon-weixin:before { + content: "\e665"; +} + +.icon-a-Versionhistory:before { + content: "\e673"; +} + +.icon-file3:before { + content: "\e672"; +} + .icon-a-copylink:before { content: "\e66f"; } +.icon-a-Viewlog:before { + content: "\e671"; +} + .icon-copy:before { content: "\e670"; } @@ -57,10 +125,6 @@ content: "\e666"; } -.icon-WeChat:before { - content: "\e665"; -} - .icon-account:before { content: "\e660"; } diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.js b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.js index 349d334616..045a2f7df6 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.js +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.js @@ -1 +1 @@ -window._iconfont_svg_string_3410006='',function(h){var l=(l=document.getElementsByTagName("script"))[l.length-1],c=l.getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,i,v,m,t,s=function(l,c){c.parentNode.insertBefore(l,c)};if(c&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}o=function(){var l,c=document.createElement("div");c.innerHTML=h._iconfont_svg_string_3410006,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?s(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(i=function(){document.removeEventListener("DOMContentLoaded",i,!1),o()},document.addEventListener("DOMContentLoaded",i,!1)):document.attachEvent&&(v=o,m=h.document,t=!1,a(),m.onreadystatechange=function(){"complete"==m.readyState&&(m.onreadystatechange=null,z())})}function z(){t||(t=!0,v())}function a(){try{m.documentElement.doScroll("left")}catch(l){return void setTimeout(a,50)}z()}}(window); \ No newline at end of file +window._iconfont_svg_string_3410006='',function(h){var l=(l=document.getElementsByTagName("script"))[l.length-1],c=l.getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var o,i,v,m,s,t=function(l,c){c.parentNode.insertBefore(l,c)};if(c&&!h.__iconfont__svg__cssinject__){h.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}o=function(){var l,c=document.createElement("div");c.innerHTML=h._iconfont_svg_string_3410006,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?t(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(i=function(){document.removeEventListener("DOMContentLoaded",i,!1),o()},document.addEventListener("DOMContentLoaded",i,!1)):document.attachEvent&&(v=o,m=h.document,s=!1,a(),m.onreadystatechange=function(){"complete"==m.readyState&&(m.onreadystatechange=null,z())})}function z(){s||(s=!0,v())}function a(){try{m.documentElement.doScroll("left")}catch(l){return void setTimeout(a,50)}z()}}(window); \ No newline at end of file diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.json b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.json index 2a64f26637..ad86a59f6b 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.json +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.json @@ -6,12 +6,131 @@ "description": "", "glyphs": [ { - "icon_id": "36317040", + "icon_id": "36454579", + "name": "Build Image", + "font_class": "a-BuildImage", + "unicode": "e674", + "unicode_decimal": 58996 + }, + { + "icon_id": "36454580", + "name": "Cancel", + "font_class": "Cancel", + "unicode": "e675", + "unicode_decimal": 58997 + }, + { + "icon_id": "36454581", + "name": "Restore", + "font_class": "Restore", + "unicode": "e676", + "unicode_decimal": 58998 + }, + { + "icon_id": "36454582", + "name": "Push local", + "font_class": "a-Pushlocal", + "unicode": "e677", + "unicode_decimal": 58999 + }, + { + "icon_id": "36454583", + "name": "Security Settings", + "font_class": "a-SecuritySettings", + "unicode": "e678", + "unicode_decimal": 59000 + }, + { + "icon_id": "36454584", + "name": "online evaluation", + "font_class": "a-onlineevaluation", + "unicode": "e679", + "unicode_decimal": 59001 + }, + { + "icon_id": "36454585", + "name": "fold2", + "font_class": "fold21", + "unicode": "e67a", + "unicode_decimal": 59002 + }, + { + "icon_id": "36454586", + "name": "Rerun", + "font_class": "Rerun", + "unicode": "e67b", + "unicode_decimal": 59003 + }, + { + "icon_id": "36454587", + "name": "unfold2", + "font_class": "unfold21", + "unicode": "e67c", + "unicode_decimal": 59004 + }, + { + "icon_id": "36454588", + "name": "View Tasks", + "font_class": "a-ViewTasks", + "unicode": "e67d", + "unicode_decimal": 59005 + }, + { + "icon_id": "36454589", + "name": "Image Built", + "font_class": "a-ImageBuilt", + "unicode": "e67e", + "unicode_decimal": 59006 + }, + { + "icon_id": "36454591", + "name": "link", + "font_class": "link", + "unicode": "e680", + "unicode_decimal": 59008 + }, + { + "icon_id": "36454592", + "name": "run model", + "font_class": "a-runmodel", + "unicode": "e681", + "unicode_decimal": 59009 + }, + { + "icon_id": "36453407", + "name": "微信", + "font_class": "weixin", + "unicode": "e665", + "unicode_decimal": 58981 + }, + { + "icon_id": "36432264", + "name": "Version history", + "font_class": "a-Versionhistory", + "unicode": "e673", + "unicode_decimal": 58995 + }, + { + "icon_id": "36423030", + "name": "file3", + "font_class": "file3", + "unicode": "e672", + "unicode_decimal": 58994 + }, + { + "icon_id": "36414623", "name": "copy link", "font_class": "a-copylink", "unicode": "e66f", "unicode_decimal": 58991 }, + { + "icon_id": "36414624", + "name": "View log", + "font_class": "a-Viewlog", + "unicode": "e671", + "unicode_decimal": 58993 + }, { "icon_id": "36317041", "name": "copy", @@ -82,13 +201,6 @@ "unicode": "e666", "unicode_decimal": 58982 }, - { - "icon_id": "35260847", - "name": "WeChat", - "font_class": "WeChat", - "unicode": "e665", - "unicode_decimal": 58981 - }, { "icon_id": "35120427", "name": "account", diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.ttf b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.ttf index 221743d7eb..afa91b8743 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.ttf +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.ttf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:091baf54f607f257ab2e6b1e701bb8b8e576710d8d261eed78781b906c92a433 -size 20776 +oid sha256:5c0bdbd5d363c11694f1430663ffac9f3e0064171ca9da9289efc8ddf2f09f8a +size 24420 diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff index 3b41e774dc..c4f7a65486 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2ba650f46fe20e13547d996d808704bd2005554ec8b1ead89f2774bef673dcc3 -size 11972 +oid sha256:7041db9e9bad2a64e8cf6ac7bee20e4d167ce4a542d21bb34eaabb7f13bc96ee +size 13764 diff --git a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff2 b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff2 index 8eafa83ec0..115e9ba391 100644 --- a/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff2 +++ b/console/packages/starwhale-ui/src/IconFont/fonts/iconfont.woff2 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac49aed478de2396faa7eb029ec3bbaebe267ba29393c0170bb2b3a75336bf77 -size 9996 +oid sha256:a460136a6c094c6549eb1c7944b7de802c7673bc584f27d3dca12921de8375db +size 11520 diff --git a/console/packages/starwhale-ui/src/IconFont/index.tsx b/console/packages/starwhale-ui/src/IconFont/index.tsx index 01af29bb42..1351290080 100644 --- a/console/packages/starwhale-ui/src/IconFont/index.tsx +++ b/console/packages/starwhale-ui/src/IconFont/index.tsx @@ -89,6 +89,7 @@ export type IconTypesT = | 'a-passwordresets' | 'file' | 'file2' + | 'file3' | 'check' | 'invalidFile' | 'group' @@ -115,6 +116,10 @@ export type IconTypesT = | 'global2' | 'copy' | 'a-copylink' + | 'upload' + | 'a-Versionhistory' + | 'fold21' + | 'unfold21' interface IIconFontProps { style?: React.CSSProperties diff --git a/console/packages/starwhale-ui/src/Upload/DraggerUpload.tsx b/console/packages/starwhale-ui/src/Upload/DraggerUpload.tsx new file mode 100644 index 0000000000..85fd460257 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/DraggerUpload.tsx @@ -0,0 +1,400 @@ +import React, { useEffect } from 'react' +import { findMostFrequentType, getSignUrls, getUploadName, pickAttr } from './utils' +import Button from '../Button' +import { createUseStyles } from 'react-jss' +import useTranslation from '@/hooks/useTranslation' +import { getReadableStorageQuantityStr } from '../utils/index' +import Text from '../Text/Text' +import { useDropzone } from './react-dropzone' +import { deleteFiles, sign } from '@/domain/base/services/filestore' +import _ from 'lodash' +import useUploadingControl from './hooks/useUploadingControl' +import { UploadFile } from './types' +import { ItemRender } from './UploadItem' +import { useEvent } from '@starwhale/core' +import { useSign } from './hooks/useSign' +import axios from 'axios' +import IconFont from '../IconFont' + +const useStyles = createUseStyles({ + drag: { + 'display': 'flex', + 'alignContent': 'stretch', + 'alignItems': 'flex-start', + 'flexDirection': 'column', + 'gap': '8px', + '& .ant-upload-drag': { + backgroundColor: '#FAFBFC !important', + }, + '& .ant-upload-btn': { + 'height': '170px !important', + 'alignItems': 'center', + 'justifyContent': 'center', + ':hover': { + border: '1px solid #5181E0; !important', + }, + }, + '& .ant-upload-icon': { + margin: '0 8px 0 12px', + }, + '& .ant-upload-drag-icon': { + color: 'rgba(2,16,43,0.40) !important', + marginBottom: '10px !important', + }, + '& .ant-upload-text': { + color: 'rgba(2,16,43,0.40) !important', + marginBottom: '30px !important', + fontSize: '14px !important', + }, + '& .ant-upload-action': { + gap: '20px', + display: 'flex', + justifyContent: 'center', + alignContent: 'center', + }, + '& .ant-upload-list': { + maxHeight: '230px', + width: '100%', + overflow: 'auto', + }, + '& .ant-upload-list-item-container': { + 'height': '32px !important', + 'borderBottom': '1px solid #EEF1F6', + '&:hover': { + backgroundColor: '#EBF1FF !important', + }, + }, + '& .ant-upload-list-item': { + height: '31px !important', + display: 'flex', + alignItems: 'center', + marginTop: '0px !important', + fontSize: '14px !important', + paddingRight: '8px', + }, + '& .ant-upload-list-item-name': { + flex: 1, + }, + '& .ant-upload-list-item-size': { + color: ' rgba(2,16,43,0.40)', + }, + }, +}) +const baseStyle = { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '20px', + borderWidth: 1, + borderRadius: 2, + borderColor: '#CFD7E6;', + borderStyle: 'dashed', + backgroundColor: '#fafbfc', + color: '#bdbdbd', + outline: 'none', + transition: 'border .24s ease-in-out', +} +const focusedStyle = { + borderColor: '#5181E0', +} +const acceptStyle = { + borderColor: '#5181E0', +} +const rejectStyle = { + borderColor: '#5181E0', +} + +type ValueT = { storagePath: string; type: string } +interface IDraggerUploadProps { + value?: ValueT + onChange?: (data: ValueT) => void +} + +const UPLOAD_MAX = 1000 +type UploadControlT = { + file: UploadFile + oss: string + resp: any +} +function DraggerUpload({ onChange }: IDraggerUploadProps) { + const styles = useStyles() + const [t] = useTranslation() + const { resetSign, signPrefix } = useSign() + const [fileList, setFileList] = React.useState([]) + const [fileSuccessList, setFileSuccessList] = React.useState([]) + const [fileUploadingList, setFileUploadingList] = React.useState([]) + const [fileFailedList, setFileFailedList] = React.useState([]) + + // first memoe + const statusMap: Record = React.useMemo(() => { + return _.groupBy([...fileSuccessList, ...fileFailedList], 'status') as any + }, [fileSuccessList, fileFailedList]) + const isMax = React.useMemo(() => fileList.length - fileFailedList.length > UPLOAD_MAX, [fileList, fileFailedList]) + const isExist = React.useCallback( + (tmp) => fileSuccessList.some((f: any) => f.path === getUploadName(tmp)), + [fileSuccessList] + ) + const beforeUpload = (file: UploadFile) => { + // console.log('beforeUpload', file, !!isExist(file)) + if (isMax) { + setFileFailedList((prev) => [ + ...prev, + { + ...file, + status: 'errorMax' as any, + }, + ]) + return false + } + if (isExist(file)) { + setFileFailedList((prev) => [ + ...prev, + { + ...file, + status: 'errorExist' as any, + }, + ]) + return false + } + return true + } + + // use hooks + const { uploadQueue, cancel: cancelQueue } = useUploadingControl({ + onUpload: useEvent((file) => { + const uninterceptedAxiosInstance = axios.create() + setFileUploadingList((prev) => { + return [ + ...prev, + { + ...pickAttr(file.file), + status: 'uploading', + }, + ] + }) + return uninterceptedAxiosInstance({ + method: 'PUT', + url: file.oss, + headers: { + 'Content-Type': file.file.type, + 'X-Requested-With': 'XMLHttpRequest', + }, + data: file.file, + onUploadProgress: (p) => { + setFileUploadingList((prev) => { + const index = prev.findIndex((f) => f.path === file.file.path) + if (index === -1) { + return [...prev] + } + const newFile = { + ...prev[index], + percent: 100 * (p.loaded / p.total), + } + prev.splice(index, 1, newFile) + return [...prev] + }) + return 100 * (p.loaded / p.total) + }, + onDownloadProgress: (p) => { + return 100 * (p.loaded / p.total) + }, + }) + }), + onDone: (file: UploadFile) => { + setFileSuccessList((prev) => [ + ...prev, + { + ...pickAttr(file), + status: 'done', + }, + ]) + setFileUploadingList((prev) => prev.filter((f) => f.path !== file.path)) + }, + onError: (file: UploadFile) => { + // console.log('onError', res) + setFileFailedList((prev) => [ + ...prev, + { + ...pickAttr(file), + status: 'errorUnknown', + }, + ]) + setFileUploadingList((prev) => prev.filter((f) => f.path !== file.path)) + }, + }) + const { getRootProps, getInputProps, isFocused, isDragAccept, isDragReject } = useDropzone({ + // Note how this callback is never invoked if drop occurs on the inner dropzone + // @ts-ignore + onDrop: async (files: UploadFile[]) => { + const validateFiles: any[] = [] + files.forEach((file) => { + if (!beforeUpload(file)) { + return + } + validateFiles.push(file) + }) + if (validateFiles.length === 0) return + setFileList((prev) => [...prev, ...validateFiles]) + const signedUrls = await getSignUrls(validateFiles) + const data = await sign(signedUrls, signPrefix) + files.forEach((file) => { + uploadQueue.next({ + file, + oss: data.signedUrls[file.path], + } as UploadControlT) + }) + }, + }) + + const handleReset = useEvent(() => { + resetSign() + setFileList([]) + setFileSuccessList([]) + setFileFailedList([]) + setFileUploadingList([]) + cancelQueue() + }) + + const handleRemove = useEvent(async (file: UploadFile) => { + const name = getUploadName(file) + await deleteFiles([name], signPrefix) + setFileList((prev) => prev.filter((f) => f.path !== file.path)) + setFileSuccessList((prev) => prev.filter((f) => f.path !== file.path)) + setFileFailedList((prev) => prev.filter((f) => f.path !== file.path)) + }) + + // memo + const mostFrequentType = React.useMemo(() => { + if (!fileSuccessList?.length) return null + return findMostFrequentType(fileSuccessList) + }, [fileSuccessList]) + const total = fileSuccessList?.length ?? 0 + const totalSize = getReadableStorageQuantityStr(fileSuccessList?.reduce((acc, cur) => acc + cur.size ?? 0, 0) ?? 0) + const getFileName = (f: UploadFile) => getUploadName(f) + const style = React.useMemo( + () => ({ + ...baseStyle, + ...(isFocused ? focusedStyle : {}), + ...(isDragAccept ? acceptStyle : {}), + ...(isDragReject ? rejectStyle : {}), + }), + [isFocused, isDragAccept, isDragReject] + ) + // const dir = { directory: 'true', webkitdirectory: 'true' } + const dir = {} + const itemsUploading = React.useMemo( + () => + fileUploadingList.map((file) => ( + + )), + [fileUploadingList, handleRemove] + ) + const items = React.useMemo( + () => fileSuccessList.map((file) => ), + [fileSuccessList, handleRemove] + ) + // @ts-ignore + const successCount = ['done', 'success'].reduce((acc, cur: any) => acc + (statusMap[cur]?.length ?? 0), 0) + const errorCount = ['error', 'errorExist', 'errorMax', 'errorUnknown'].reduce( + // @ts-ignore + (acc, cur: any) => acc + (statusMap[cur]?.length ?? 0), + 0 + ) + + // effect + useEffect(() => { + const timer = setTimeout(() => { + const isDone = fileList?.every((item) => item.status !== 'uploading') + + if (!isDone) { + return + } + const type = fileSuccessList ? findMostFrequentType(fileSuccessList) : '' + onChange?.({ + storagePath: signPrefix, + type, + }) + }, 1000) + return () => { + clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileList, fileSuccessList, signPrefix]) + + // console.log(fileSuccessList, fileUploadingList) + + return ( +
+ {/* */} +
+ +

{t('dataset.create.upload.drag.desc')}

+
+ {(total > 0 || fileUploadingList.length > 0) && ( +
+ +
+ )} +
+ {itemsUploading} + {items} +
+ {total > 0 && ( +
+
+

+ {t('dataset.create.upload.total.desc', [successCount, totalSize])} +

+ {errorCount ? ( +

+ {t('dataset.create.upload.error.desc')} + {statusMap.errorExist && statusMap.errorExist?.length && ( + {statusMap.errorExist.map(getFileName).join('\n')}} + > + {statusMap.errorExist.length}  ({t('dataset.create.upload.error.exist')}) + + )} + {statusMap.errorMax && statusMap.errorMax?.length && ( + {statusMap.errorMax.map(getFileName).join('\n')}} + > + {statusMap.errorMax.length} ({t('dataset.create.upload.error.max')}) + + )} + {statusMap.errorUnknown && statusMap.errorUnknown?.length && ( + {statusMap.errorUnknown.map(getFileName).join('\n')}} + > + {statusMap.errorUnknown.length} ({t('dataset.create.upload.error.unknown')} + ) + + )} +

+ ) : null} + {!mostFrequentType && ( +

{t('dataset.create.upload.error.type')}

+ )} +
+
+ )} +
+ ) +} + +export { DraggerUpload } +export default DraggerUpload diff --git a/console/packages/starwhale-ui/src/Upload/UploadItem.tsx b/console/packages/starwhale-ui/src/Upload/UploadItem.tsx new file mode 100644 index 0000000000..3c369ae225 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/UploadItem.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { getUploadName, getUploadType } from './utils' +import IconFont from '@starwhale/ui/IconFont' +import { getReadableStorageQuantityStr } from '@starwhale/ui/utils' +import Button, { IButtonProps } from '@starwhale/ui/Button' +import { UploadFile } from './types' +import { ProgressBar } from 'baseui/progress-bar' +import { expandMargin } from '@/utils' + +const overrides: IButtonProps['overrides'] = { + BaseButton: { + style: { + 'marginLeft': '16px', + 'backgroundColor': 'transparent', + 'color': ' rgba(2,16,43,0.40);', + ':hover .icon-container': { + color: '#D65E5E !important', + backgroundColor: 'transparent', + }, + ':focus': { + color: '#D65E5E !important', + backgroundColor: 'transparent', + }, + }, + }, +} + +export function ItemRender({ + percent = -1, + file, + onRemove, +}: { + percent?: number + file: UploadFile + onRemove?: (file: UploadFile) => void +}) { + // return originNode + const type = getUploadType(file) + const name = getUploadName(file) + const icons = { + IMAGE: 'image', + VIDEO: 'video', + AUDIO: 'audio', + CSV: 'txt', + JSON: 'txt', + JSONL: 'txt', + } + + return ( +
+
+
+ {/* @ts-ignore */} + +
+
{name}
+
{getReadableStorageQuantityStr(file.size ?? 0)}
+
+
+
+ {percent >= 0 && ( + + )} +
+ ) +} diff --git a/console/packages/starwhale-ui/src/Upload/hooks/useSign.ts b/console/packages/starwhale-ui/src/Upload/hooks/useSign.ts new file mode 100644 index 0000000000..d79a4dd11c --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/hooks/useSign.ts @@ -0,0 +1,28 @@ +import { fetchSignPathPrefix } from '@/domain/base/services/filestore' +import { useEffect, useRef } from 'react' + +function useSign() { + const signPrefix = useRef('') + + const getSign = async () => { + if (!signPrefix.current) { + signPrefix.current = await fetchSignPathPrefix() + } + + return signPrefix.current + } + + useEffect(() => { + getSign() + }, []) + + return { + getSign, + resetSign: async () => { + signPrefix.current = await fetchSignPathPrefix() + }, + signPrefix: signPrefix.current, + } +} + +export { useSign } diff --git a/console/packages/starwhale-ui/src/Upload/hooks/useUploadingControl.ts b/console/packages/starwhale-ui/src/Upload/hooks/useUploadingControl.ts new file mode 100644 index 0000000000..44746ccc0f --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/hooks/useUploadingControl.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Subject, Subscription, catchError, from, mergeMap, of, takeUntil } from 'rxjs' + +function useUploadingControl({ + concurrent = 4, + onUpload, + onDone, + onError, +}: { + concurrent?: number + onUpload: (args: T) => Promise + onDone?: (args: any) => void + onError?: (args: any, error: any) => void +}) { + const uploadQRef = useRef>(new Subject()) + const stopSignalRef = useRef>(new Subject()) + + useEffect(() => { + const subscription = new Subscription() + const uploadQ = uploadQRef.current + const uploadQSubscription = uploadQ + .asObservable() + .pipe( + mergeMap((args: T) => { + return from(onUpload(args)).pipe(catchError((error) => of(error))) + }, concurrent), + takeUntil(stopSignalRef.current) + ) + .subscribe({ + next: (res: any) => { + const file = res.config?.data + if (!file) return + + if (res instanceof Error) { + onError?.(file, res) + return + } + + onDone?.(file) + }, + }) + + subscription.add(uploadQSubscription) + + // new Array(concurrent).fill(100).map(() => uploadQ.next({ test: new Date() })) + + return () => { + subscription.unsubscribe() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stopSignalRef.current]) + + return { + uploadQueue: uploadQRef.current, + cancel: () => { + stopSignalRef.current.next() + stopSignalRef.current.complete() + stopSignalRef.current = new Subject() + }, + } +} + +export { useUploadingControl } +export default useUploadingControl diff --git a/console/packages/starwhale-ui/src/Upload/index.tsx b/console/packages/starwhale-ui/src/Upload/index.tsx new file mode 100644 index 0000000000..db83ff8c3e --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/index.tsx @@ -0,0 +1 @@ +export * from './DraggerUpload' diff --git a/console/packages/starwhale-ui/src/Upload/react-dropzone/.eslintrc b/console/packages/starwhale-ui/src/Upload/react-dropzone/.eslintrc new file mode 100644 index 0000000000..aebb9021e4 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/react-dropzone/.eslintrc @@ -0,0 +1,37 @@ +{ + "root": true, + "parser": "@babel/eslint-parser", + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "jest": true + }, + "plugins": [ + "import", + "jsx-a11y", + "prettier", + "react", + "react-hooks" + ], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:jsx-a11y/recommended", + "plugin:prettier/recommended" + ], + "rules": { + "react-hooks/rules-of-hooks": 2, + "react/forbid-prop-types": 0, + "react/require-default-props": 0 + }, + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/console/packages/starwhale-ui/src/Upload/react-dropzone/index.jsx b/console/packages/starwhale-ui/src/Upload/react-dropzone/index.jsx new file mode 100755 index 0000000000..67c6df00e4 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/react-dropzone/index.jsx @@ -0,0 +1,988 @@ +/* eslint prefer-template: 0 */ +import React, { + forwardRef, + Fragment, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useReducer, + useRef, +} from 'react' +import PropTypes from 'prop-types' +import { fromEvent } from 'file-selector' +import { + acceptPropAsAcceptAttr, + allFilesAccepted, + composeEventHandlers, + fileAccepted, + fileMatchSize, + canUseFileSystemAccessAPI, + isAbort, + isEvtWithFiles, + isIeOrEdge, + isPropagationStopped, + isSecurityError, + onDocumentDragOver, + pickerOptionsFromAccept, + TOO_MANY_FILES_REJECTION, +} from './utils/index' + +/** + * Convenience wrapper component for the `useDropzone` hook + * + * ```jsx + * + * {({getRootProps, getInputProps}) => ( + *
+ * + *

Drag 'n' drop some files here, or click to select files

+ *
+ * )} + *
+ * ``` + */ +const Dropzone = forwardRef(({ children, ...params }, ref) => { + const { open, ...props } = useDropzone(params) + + useImperativeHandle(ref, () => ({ open }), [open]) + + // TODO: Figure out why react-styleguidist cannot create docs if we don't return a jsx element + return {children({ ...props, open })} +}) + +Dropzone.displayName = 'Dropzone' + +// Add default props for react-docgen +const defaultProps = { + disabled: false, + getFilesFromEvent: fromEvent, + maxSize: Infinity, + minSize: 0, + multiple: true, + maxFiles: 0, + preventDropOnDocument: true, + noClick: false, + noKeyboard: false, + noDrag: false, + noDragEventsBubbling: false, + validator: null, + useFsAccessApi: true, + autoFocus: false, +} + +Dropzone.defaultProps = defaultProps + +Dropzone.propTypes = { + /** + * Render function that exposes the dropzone state and prop getter fns + * + * @param {object} params + * @param {Function} params.getRootProps Returns the props you should apply to the root drop container you render + * @param {Function} params.getInputProps Returns the props you should apply to hidden file input you render + * @param {Function} params.open Open the native file selection dialog + * @param {boolean} params.isFocused Dropzone area is in focus + * @param {boolean} params.isFileDialogActive File dialog is opened + * @param {boolean} params.isDragActive Active drag is in progress + * @param {boolean} params.isDragAccept Dragged files are accepted + * @param {boolean} params.isDragReject Some dragged files are rejected + * @param {File[]} params.acceptedFiles Accepted files + * @param {FileRejection[]} params.fileRejections Rejected files and why they were rejected + */ + children: PropTypes.func, + + /** + * Set accepted file types. + * Checkout https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker types option for more information. + * Keep in mind that mime type determination is not reliable across platforms. CSV files, + * for example, are reported as text/plain under macOS but as application/vnd.ms-excel under + * Windows. In some cases there might not be a mime type set at all (https://github.com/react-dropzone/react-dropzone/issues/276). + */ + accept: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), + + /** + * Allow drag 'n' drop (or selection from the file dialog) of multiple files + */ + multiple: PropTypes.bool, + + /** + * If false, allow dropped items to take over the current browser window + */ + preventDropOnDocument: PropTypes.bool, + + /** + * If true, disables click to open the native file selection dialog + */ + noClick: PropTypes.bool, + + /** + * If true, disables SPACE/ENTER to open the native file selection dialog. + * Note that it also stops tracking the focus state. + */ + noKeyboard: PropTypes.bool, + + /** + * If true, disables drag 'n' drop + */ + noDrag: PropTypes.bool, + + /** + * If true, stops drag event propagation to parents + */ + noDragEventsBubbling: PropTypes.bool, + + /** + * Minimum file size (in bytes) + */ + minSize: PropTypes.number, + + /** + * Maximum file size (in bytes) + */ + maxSize: PropTypes.number, + /** + * Maximum accepted number of files + * The default value is 0 which means there is no limitation to how many files are accepted. + */ + maxFiles: PropTypes.number, + + /** + * Enable/disable the dropzone + */ + disabled: PropTypes.bool, + + /** + * Use this to provide a custom file aggregator + * + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + */ + getFilesFromEvent: PropTypes.func, + + /** + * Cb for when closing the file dialog with no selection + */ + onFileDialogCancel: PropTypes.func, + + /** + * Cb for when opening the file dialog + */ + onFileDialogOpen: PropTypes.func, + + /** + * Set to true to use the https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API + * to open the file picker instead of using an `` click event. + */ + useFsAccessApi: PropTypes.bool, + + /** + * Set to true to focus the root element on render + */ + autoFocus: PropTypes.bool, + + /** + * Cb for when the `dragenter` event occurs. + * + * @param {DragEvent} event + */ + onDragEnter: PropTypes.func, + + /** + * Cb for when the `dragleave` event occurs + * + * @param {DragEvent} event + */ + onDragLeave: PropTypes.func, + + /** + * Cb for when the `dragover` event occurs + * + * @param {DragEvent} event + */ + onDragOver: PropTypes.func, + + /** + * Cb for when the `drop` event occurs. + * Note that this callback is invoked after the `getFilesFromEvent` callback is done. + * + * Files are accepted or rejected based on the `accept`, `multiple`, `minSize` and `maxSize` props. + * `accept` must be a valid [MIME type](http://www.iana.org/assignments/media-types/media-types.xhtml) according to [input element specification](https://www.w3.org/wiki/HTML/Elements/input/file) or a valid file extension. + * If `multiple` is set to false and additional files are dropped, + * all files besides the first will be rejected. + * Any file which does not have a size in the [`minSize`, `maxSize`] range, will be rejected as well. + * + * Note that the `onDrop` callback will always be invoked regardless if the dropped files were accepted or rejected. + * If you'd like to react to a specific scenario, use the `onDropAccepted`/`onDropRejected` props. + * + * `onDrop` will provide you with an array of [File](https://developer.mozilla.org/en-US/docs/Web/API/File) objects which you can then process and send to a server. + * For example, with [SuperAgent](https://github.com/visionmedia/superagent) as a http/ajax library: + * + * ```js + * function onDrop(acceptedFiles) { + * const req = request.post('/upload') + * acceptedFiles.forEach(file => { + * req.attach(file.name, file) + * }) + * req.end(callback) + * } + * ``` + * + * @param {File[]} acceptedFiles + * @param {FileRejection[]} fileRejections + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + */ + onDrop: PropTypes.func, + + /** + * Cb for when the `drop` event occurs. + * Note that if no files are accepted, this callback is not invoked. + * + * @param {File[]} files + * @param {(DragEvent|Event)} event + */ + onDropAccepted: PropTypes.func, + + /** + * Cb for when the `drop` event occurs. + * Note that if no files are rejected, this callback is not invoked. + * + * @param {FileRejection[]} fileRejections + * @param {(DragEvent|Event)} event + */ + onDropRejected: PropTypes.func, + + /** + * Cb for when there's some error from any of the promises. + * + * @param {Error} error + */ + onError: PropTypes.func, + + /** + * Custom validation function. It must return null if there's no errors. + * @param {File} file + * @returns {FileError|FileError[]|null} + */ + validator: PropTypes.func, +} + +export default Dropzone + +/** + * A function that is invoked for the `dragenter`, + * `dragover` and `dragleave` events. + * It is not invoked if the items are not files (such as link, text, etc.). + * + * @callback dragCb + * @param {DragEvent} event + */ + +/** + * A function that is invoked for the `drop` or input change event. + * It is not invoked if the items are not files (such as link, text, etc.). + * + * @callback dropCb + * @param {File[]} acceptedFiles List of accepted files + * @param {FileRejection[]} fileRejections List of rejected files and why they were rejected + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + */ + +/** + * A function that is invoked for the `drop` or input change event. + * It is not invoked if the items are files (such as link, text, etc.). + * + * @callback dropAcceptedCb + * @param {File[]} files List of accepted files that meet the given criteria + * (`accept`, `multiple`, `minSize`, `maxSize`) + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + */ + +/** + * A function that is invoked for the `drop` or input change event. + * + * @callback dropRejectedCb + * @param {File[]} files List of rejected files that do not meet the given criteria + * (`accept`, `multiple`, `minSize`, `maxSize`) + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + */ + +/** + * A function that is used aggregate files, + * in a asynchronous fashion, from drag or input change events. + * + * @callback getFilesFromEvent + * @param {(DragEvent|Event)} event A drag event or input change event (if files were selected via the file dialog) + * @returns {(File[]|Promise)} + */ + +/** + * An object with the current dropzone state. + * + * @typedef {object} DropzoneState + * @property {boolean} isFocused Dropzone area is in focus + * @property {boolean} isFileDialogActive File dialog is opened + * @property {boolean} isDragActive Active drag is in progress + * @property {boolean} isDragAccept Dragged files are accepted + * @property {boolean} isDragReject Some dragged files are rejected + * @property {File[]} acceptedFiles Accepted files + * @property {FileRejection[]} fileRejections Rejected files and why they were rejected + */ + +/** + * An object with the dropzone methods. + * + * @typedef {object} DropzoneMethods + * @property {Function} getRootProps Returns the props you should apply to the root drop container you render + * @property {Function} getInputProps Returns the props you should apply to hidden file input you render + * @property {Function} open Open the native file selection dialog + */ + +const initialState = { + isFocused: false, + isFileDialogActive: false, + isDragActive: false, + isDragAccept: false, + isDragReject: false, + acceptedFiles: [], + fileRejections: [], +} + +/** + * A React hook that creates a drag 'n' drop area. + * + * ```jsx + * function MyDropzone(props) { + * const {getRootProps, getInputProps} = useDropzone({ + * onDrop: acceptedFiles => { + * // do something with the File objects, e.g. upload to some server + * } + * }); + * return ( + *
+ * + *

Drag and drop some files here, or click to select files

+ *
+ * ) + * } + * ``` + * + * @function useDropzone + * + * @param {object} props + * @param {import("./utils").AcceptProp} [props.accept] Set accepted file types. + * Checkout https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker types option for more information. + * Keep in mind that mime type determination is not reliable across platforms. CSV files, + * for example, are reported as text/plain under macOS but as application/vnd.ms-excel under + * Windows. In some cases there might not be a mime type set at all (https://github.com/react-dropzone/react-dropzone/issues/276). + * @param {boolean} [props.multiple=true] Allow drag 'n' drop (or selection from the file dialog) of multiple files + * @param {boolean} [props.preventDropOnDocument=true] If false, allow dropped items to take over the current browser window + * @param {boolean} [props.noClick=false] If true, disables click to open the native file selection dialog + * @param {boolean} [props.noKeyboard=false] If true, disables SPACE/ENTER to open the native file selection dialog. + * Note that it also stops tracking the focus state. + * @param {boolean} [props.noDrag=false] If true, disables drag 'n' drop + * @param {boolean} [props.noDragEventsBubbling=false] If true, stops drag event propagation to parents + * @param {number} [props.minSize=0] Minimum file size (in bytes) + * @param {number} [props.maxSize=Infinity] Maximum file size (in bytes) + * @param {boolean} [props.disabled=false] Enable/disable the dropzone + * @param {getFilesFromEvent} [props.getFilesFromEvent] Use this to provide a custom file aggregator + * @param {Function} [props.onFileDialogCancel] Cb for when closing the file dialog with no selection + * @param {boolean} [props.useFsAccessApi] Set to true to use the https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API + * to open the file picker instead of using an `` click event. + * @param {boolean} autoFocus Set to true to auto focus the root element. + * @param {Function} [props.onFileDialogOpen] Cb for when opening the file dialog + * @param {dragCb} [props.onDragEnter] Cb for when the `dragenter` event occurs. + * @param {dragCb} [props.onDragLeave] Cb for when the `dragleave` event occurs + * @param {dragCb} [props.onDragOver] Cb for when the `dragover` event occurs + * @param {dropCb} [props.onDrop] Cb for when the `drop` event occurs. + * Note that this callback is invoked after the `getFilesFromEvent` callback is done. + * + * Files are accepted or rejected based on the `accept`, `multiple`, `minSize` and `maxSize` props. + * `accept` must be an object with keys as a valid [MIME type](http://www.iana.org/assignments/media-types/media-types.xhtml) according to [input element specification](https://www.w3.org/wiki/HTML/Elements/input/file) and the value an array of file extensions (optional). + * If `multiple` is set to false and additional files are dropped, + * all files besides the first will be rejected. + * Any file which does not have a size in the [`minSize`, `maxSize`] range, will be rejected as well. + * + * Note that the `onDrop` callback will always be invoked regardless if the dropped files were accepted or rejected. + * If you'd like to react to a specific scenario, use the `onDropAccepted`/`onDropRejected` props. + * + * `onDrop` will provide you with an array of [File](https://developer.mozilla.org/en-US/docs/Web/API/File) objects which you can then process and send to a server. + * For example, with [SuperAgent](https://github.com/visionmedia/superagent) as a http/ajax library: + * + * ```js + * function onDrop(acceptedFiles) { + * const req = request.post('/upload') + * acceptedFiles.forEach(file => { + * req.attach(file.name, file) + * }) + * req.end(callback) + * } + * ``` + * @param {dropAcceptedCb} [props.onDropAccepted] + * @param {dropRejectedCb} [props.onDropRejected] + * @param {(error: Error) => void} [props.onError] + * + * @returns {DropzoneState & DropzoneMethods} + */ +export function useDropzone(props = {}) { + const { + accept, + disabled, + getFilesFromEvent, + maxSize, + minSize, + multiple, + maxFiles, + onDragEnter, + onDragLeave, + onDragOver, + onDrop, + onDropAccepted, + onDropRejected, + onFileDialogCancel, + onFileDialogOpen, + useFsAccessApi, + autoFocus, + preventDropOnDocument, + noClick, + noKeyboard, + noDrag, + noDragEventsBubbling, + onError, + validator, + } = { + ...defaultProps, + ...props, + } + + const acceptAttr = useMemo(() => acceptPropAsAcceptAttr(accept), [accept]) + const pickerTypes = useMemo(() => pickerOptionsFromAccept(accept), [accept]) + + const onFileDialogOpenCb = useMemo( + () => (typeof onFileDialogOpen === 'function' ? onFileDialogOpen : noop), + [onFileDialogOpen] + ) + const onFileDialogCancelCb = useMemo( + () => (typeof onFileDialogCancel === 'function' ? onFileDialogCancel : noop), + [onFileDialogCancel] + ) + + /** + * @constant + * @type {React.MutableRefObject} + */ + const rootRef = useRef(null) + + const inputRef = useRef(null) + + const [state, dispatch] = useReducer(reducer, initialState) + const { isFocused, isFileDialogActive } = state + + const fsAccessApiWorksRef = useRef( + typeof window !== 'undefined' && window.isSecureContext && useFsAccessApi && canUseFileSystemAccessAPI() + ) + + // Update file dialog active state when the window is focused on + const onWindowFocus = () => { + // Execute the timeout only if the file dialog is opened in the browser + if (!fsAccessApiWorksRef.current && isFileDialogActive) { + setTimeout(() => { + if (inputRef.current) { + const { files } = inputRef.current + + if (!files.length) { + dispatch({ type: 'closeDialog' }) + onFileDialogCancelCb() + } + } + }, 300) + } + } + useEffect(() => { + window.addEventListener('focus', onWindowFocus, false) + return () => { + window.removeEventListener('focus', onWindowFocus, false) + } + }, [inputRef, isFileDialogActive, onFileDialogCancelCb, fsAccessApiWorksRef]) + + const dragTargetsRef = useRef([]) + const onDocumentDrop = (event) => { + if (rootRef.current && rootRef.current.contains(event.target)) { + // If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler + return + } + event.preventDefault() + dragTargetsRef.current = [] + } + + useEffect(() => { + if (preventDropOnDocument) { + document.addEventListener('dragover', onDocumentDragOver, false) + document.addEventListener('drop', onDocumentDrop, false) + } + + return () => { + if (preventDropOnDocument) { + document.removeEventListener('dragover', onDocumentDragOver) + document.removeEventListener('drop', onDocumentDrop) + } + } + }, [rootRef, preventDropOnDocument]) + + // Auto focus the root when autoFocus is true + useEffect(() => { + if (!disabled && autoFocus && rootRef.current) { + rootRef.current.focus() + } + return () => {} + }, [rootRef, autoFocus, disabled]) + + const onErrCb = useCallback( + (e) => { + if (onError) { + onError(e) + } else { + // Let the user know something's gone wrong if they haven't provided the onError cb. + console.error(e) + } + }, + [onError] + ) + + const onDragEnterCb = useCallback( + (event) => { + event.preventDefault() + // Persist here because we need the event later after getFilesFromEvent() is done + event.persist() + stopPropagation(event) + + dragTargetsRef.current = [...dragTargetsRef.current, event.target] + + if (isEvtWithFiles(event)) { + Promise.resolve(getFilesFromEvent(event)) + .then((files) => { + if (isPropagationStopped(event) && !noDragEventsBubbling) { + return + } + + const fileCount = files.length + const isDragAccept = + fileCount > 0 && + allFilesAccepted({ + files, + accept: acceptAttr, + minSize, + maxSize, + multiple, + maxFiles, + validator, + }) + const isDragReject = fileCount > 0 && !isDragAccept + + dispatch({ + isDragAccept, + isDragReject, + isDragActive: true, + type: 'setDraggedFiles', + }) + + if (onDragEnter) { + onDragEnter(event) + } + }) + .catch((e) => onErrCb(e)) + } + }, + [ + getFilesFromEvent, + onDragEnter, + onErrCb, + noDragEventsBubbling, + acceptAttr, + minSize, + maxSize, + multiple, + maxFiles, + validator, + ] + ) + + const onDragOverCb = useCallback( + (event) => { + event.preventDefault() + event.persist() + stopPropagation(event) + + const hasFiles = isEvtWithFiles(event) + if (hasFiles && event.dataTransfer) { + try { + event.dataTransfer.dropEffect = 'copy' + } catch {} /* eslint-disable-line no-empty */ + } + + if (hasFiles && onDragOver) { + onDragOver(event) + } + + return false + }, + [onDragOver, noDragEventsBubbling] + ) + + const onDragLeaveCb = useCallback( + (event) => { + event.preventDefault() + event.persist() + stopPropagation(event) + + // Only deactivate once the dropzone and all children have been left + const targets = dragTargetsRef.current.filter( + (target) => rootRef.current && rootRef.current.contains(target) + ) + // Make sure to remove a target present multiple times only once + // (Firefox may fire dragenter/dragleave multiple times on the same element) + const targetIdx = targets.indexOf(event.target) + if (targetIdx !== -1) { + targets.splice(targetIdx, 1) + } + dragTargetsRef.current = targets + if (targets.length > 0) { + return + } + + dispatch({ + type: 'setDraggedFiles', + isDragActive: false, + isDragAccept: false, + isDragReject: false, + }) + + if (isEvtWithFiles(event) && onDragLeave) { + onDragLeave(event) + } + }, + [rootRef, onDragLeave, noDragEventsBubbling] + ) + + const setFiles = useCallback( + (files, event) => { + const acceptedFiles = [] + const fileRejections = [] + + files.forEach((file) => { + const [accepted, acceptError] = fileAccepted(file, acceptAttr) + const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize) + const customErrors = validator ? validator(file) : null + + if (accepted && sizeMatch && !customErrors) { + acceptedFiles.push(file) + } else { + let errors = [acceptError, sizeError] + + if (customErrors) { + errors = errors.concat(customErrors) + } + + fileRejections.push({ file, errors: errors.filter((e) => e) }) + } + }) + + if ( + (!multiple && acceptedFiles.length > 1) || + (multiple && maxFiles >= 1 && acceptedFiles.length > maxFiles) + ) { + // Reject everything and empty accepted files + acceptedFiles.forEach((file) => { + fileRejections.push({ file, errors: [TOO_MANY_FILES_REJECTION] }) + }) + acceptedFiles.splice(0) + } + + dispatch({ + acceptedFiles, + fileRejections, + type: 'setFiles', + }) + + if (onDrop) { + onDrop(acceptedFiles, fileRejections, event) + } + + if (fileRejections.length > 0 && onDropRejected) { + onDropRejected(fileRejections, event) + } + + if (acceptedFiles.length > 0 && onDropAccepted) { + onDropAccepted(acceptedFiles, event) + } + }, + [dispatch, multiple, acceptAttr, minSize, maxSize, maxFiles, onDrop, onDropAccepted, onDropRejected, validator] + ) + + const onDropCb = useCallback( + (event) => { + event.preventDefault() + // Persist here because we need the event later after getFilesFromEvent() is done + event.persist() + stopPropagation(event) + + dragTargetsRef.current = [] + + if (isEvtWithFiles(event)) { + Promise.resolve(getFilesFromEvent(event)) + .then((files) => { + if (isPropagationStopped(event) && !noDragEventsBubbling) { + return + } + setFiles(files, event) + }) + .catch((e) => onErrCb(e)) + } + dispatch({ type: 'reset' }) + }, + [getFilesFromEvent, setFiles, onErrCb, noDragEventsBubbling] + ) + + // Fn for opening the file dialog programmatically + const openFileDialog = useCallback(() => { + // No point to use FS access APIs if context is not secure + // https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#feature_detection + if (fsAccessApiWorksRef.current) { + dispatch({ type: 'openDialog' }) + onFileDialogOpenCb() + // https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker + const opts = { + multiple, + types: pickerTypes, + } + window + .showOpenFilePicker(opts) + .then((handles) => getFilesFromEvent(handles)) + .then((files) => { + setFiles(files, null) + dispatch({ type: 'closeDialog' }) + }) + .catch((e) => { + // AbortError means the user canceled + if (isAbort(e)) { + onFileDialogCancelCb(e) + dispatch({ type: 'closeDialog' }) + } else if (isSecurityError(e)) { + fsAccessApiWorksRef.current = false + // CORS, so cannot use this API + // Try using the input + if (inputRef.current) { + inputRef.current.value = null + inputRef.current.click() + } else { + onErrCb( + new Error( + 'Cannot open the file picker because the https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API is not supported and no was provided.' + ) + ) + } + } else { + onErrCb(e) + } + }) + return + } + + if (inputRef.current) { + dispatch({ type: 'openDialog' }) + onFileDialogOpenCb() + inputRef.current.value = null + inputRef.current.click() + } + }, [dispatch, onFileDialogOpenCb, onFileDialogCancelCb, useFsAccessApi, setFiles, onErrCb, pickerTypes, multiple]) + + // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone + const onKeyDownCb = useCallback( + (event) => { + // Ignore keyboard events bubbling up the DOM tree + if (!rootRef.current || !rootRef.current.isEqualNode(event.target)) { + return + } + + if (event.key === ' ' || event.key === 'Enter' || event.keyCode === 32 || event.keyCode === 13) { + event.preventDefault() + openFileDialog() + } + }, + [rootRef, openFileDialog] + ) + + // Update focus state for the dropzone + const onFocusCb = useCallback(() => { + dispatch({ type: 'focus' }) + }, []) + const onBlurCb = useCallback(() => { + dispatch({ type: 'blur' }) + }, []) + + // Cb to open the file dialog when click occurs on the dropzone + const onClickCb = useCallback(() => { + if (noClick) { + return + } + + // In IE11/Edge the file-browser dialog is blocking, therefore, use setTimeout() + // to ensure React can handle state changes + // See: https://github.com/react-dropzone/react-dropzone/issues/450 + if (isIeOrEdge()) { + setTimeout(openFileDialog, 0) + } else { + openFileDialog() + } + }, [noClick, openFileDialog]) + + const composeHandler = (fn) => { + return disabled ? null : fn + } + + const composeKeyboardHandler = (fn) => { + return noKeyboard ? null : composeHandler(fn) + } + + const composeDragHandler = (fn) => { + return noDrag ? null : composeHandler(fn) + } + + const stopPropagation = (event) => { + if (noDragEventsBubbling) { + event.stopPropagation() + } + } + + const getRootProps = useMemo( + () => + ({ + refKey = 'ref', + role, + onKeyDown, + onFocus, + onBlur, + onClick, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + ...rest + } = {}) => ({ + onKeyDown: composeKeyboardHandler(composeEventHandlers(onKeyDown, onKeyDownCb)), + onFocus: composeKeyboardHandler(composeEventHandlers(onFocus, onFocusCb)), + onBlur: composeKeyboardHandler(composeEventHandlers(onBlur, onBlurCb)), + onClick: composeHandler(composeEventHandlers(onClick, onClickCb)), + onDragEnter: composeDragHandler(composeEventHandlers(onDragEnter, onDragEnterCb)), + onDragOver: composeDragHandler(composeEventHandlers(onDragOver, onDragOverCb)), + onDragLeave: composeDragHandler(composeEventHandlers(onDragLeave, onDragLeaveCb)), + onDrop: composeDragHandler(composeEventHandlers(onDrop, onDropCb)), + role: typeof role === 'string' && role !== '' ? role : 'presentation', + [refKey]: rootRef, + ...(!disabled && !noKeyboard ? { tabIndex: 0 } : {}), + ...rest, + }), + [ + rootRef, + onKeyDownCb, + onFocusCb, + onBlurCb, + onClickCb, + onDragEnterCb, + onDragOverCb, + onDragLeaveCb, + onDropCb, + noKeyboard, + noDrag, + disabled, + ] + ) + + const onInputElementClick = useCallback((event) => { + event.stopPropagation() + }, []) + + const getInputProps = useMemo( + () => + ({ refKey = 'ref', onChange, onClick, ...rest } = {}) => { + const inputProps = { + accept: acceptAttr, + multiple, + type: 'file', + style: { display: 'none' }, + onChange: composeHandler(composeEventHandlers(onChange, onDropCb)), + onClick: composeHandler(composeEventHandlers(onClick, onInputElementClick)), + tabIndex: -1, + [refKey]: inputRef, + } + + return { + ...inputProps, + ...rest, + } + }, + [inputRef, accept, multiple, onDropCb, disabled] + ) + + return { + ...state, + isFocused: isFocused && !disabled, + getRootProps, + getInputProps, + rootRef, + inputRef, + open: composeHandler(openFileDialog), + } +} + +/** + * @param {DropzoneState} state + * @param {{type: string} & DropzoneState} action + * @returns {DropzoneState} + */ +function reducer(state, action) { + /* istanbul ignore next */ + switch (action.type) { + case 'focus': + return { + ...state, + isFocused: true, + } + case 'blur': + return { + ...state, + isFocused: false, + } + case 'openDialog': + return { + ...initialState, + isFileDialogActive: true, + } + case 'closeDialog': + return { + ...state, + isFileDialogActive: false, + } + case 'setDraggedFiles': + return { + ...state, + isDragActive: action.isDragActive, + isDragAccept: action.isDragAccept, + isDragReject: action.isDragReject, + } + case 'setFiles': + return { + ...state, + acceptedFiles: action.acceptedFiles, + fileRejections: action.fileRejections, + } + case 'reset': + return { + ...initialState, + } + default: + return state + } +} + +function noop() {} + +export { ErrorCode } from './utils' diff --git a/console/packages/starwhale-ui/src/Upload/react-dropzone/utils/index.js b/console/packages/starwhale-ui/src/Upload/react-dropzone/utils/index.js new file mode 100644 index 0000000000..1d8141f911 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/react-dropzone/utils/index.js @@ -0,0 +1,322 @@ +import accepts from "attr-accept"; + +// Error codes +export const FILE_INVALID_TYPE = "file-invalid-type"; +export const FILE_TOO_LARGE = "file-too-large"; +export const FILE_TOO_SMALL = "file-too-small"; +export const TOO_MANY_FILES = "too-many-files"; + +export const ErrorCode = { + FileInvalidType: FILE_INVALID_TYPE, + FileTooLarge: FILE_TOO_LARGE, + FileTooSmall: FILE_TOO_SMALL, + TooManyFiles: TOO_MANY_FILES, +}; + +// File Errors +export const getInvalidTypeRejectionErr = (accept) => { + accept = Array.isArray(accept) && accept.length === 1 ? accept[0] : accept; + const messageSuffix = Array.isArray(accept) + ? `one of ${accept.join(", ")}` + : accept; + return { + code: FILE_INVALID_TYPE, + message: `File type must be ${messageSuffix}`, + }; +}; + +export const getTooLargeRejectionErr = (maxSize) => { + return { + code: FILE_TOO_LARGE, + message: `File is larger than ${maxSize} ${ + maxSize === 1 ? "byte" : "bytes" + }`, + }; +}; + +export const getTooSmallRejectionErr = (minSize) => { + return { + code: FILE_TOO_SMALL, + message: `File is smaller than ${minSize} ${ + minSize === 1 ? "byte" : "bytes" + }`, + }; +}; + +export const TOO_MANY_FILES_REJECTION = { + code: TOO_MANY_FILES, + message: "Too many files", +}; + +// Firefox versions prior to 53 return a bogus MIME type for every file drag, so dragovers with +// that MIME type will always be accepted +export function fileAccepted(file, accept) { + const isAcceptable = + file.type === "application/x-moz-file" || accepts(file, accept); + return [ + isAcceptable, + isAcceptable ? null : getInvalidTypeRejectionErr(accept), + ]; +} + +export function fileMatchSize(file, minSize, maxSize) { + if (isDefined(file.size)) { + if (isDefined(minSize) && isDefined(maxSize)) { + if (file.size > maxSize) return [false, getTooLargeRejectionErr(maxSize)]; + if (file.size < minSize) return [false, getTooSmallRejectionErr(minSize)]; + } else if (isDefined(minSize) && file.size < minSize) + return [false, getTooSmallRejectionErr(minSize)]; + else if (isDefined(maxSize) && file.size > maxSize) + return [false, getTooLargeRejectionErr(maxSize)]; + } + return [true, null]; +} + +function isDefined(value) { + return value !== undefined && value !== null; +} + +/** + * + * @param {object} options + * @param {File[]} options.files + * @param {string|string[]} [options.accept] + * @param {number} [options.minSize] + * @param {number} [options.maxSize] + * @param {boolean} [options.multiple] + * @param {number} [options.maxFiles] + * @param {(f: File) => FileError|FileError[]|null} [options.validator] + * @returns + */ +export function allFilesAccepted({ + files, + accept, + minSize, + maxSize, + multiple, + maxFiles, + validator, +}) { + if ( + (!multiple && files.length > 1) || + (multiple && maxFiles >= 1 && files.length > maxFiles) + ) { + return false; + } + + return files.every((file) => { + const [accepted] = fileAccepted(file, accept); + const [sizeMatch] = fileMatchSize(file, minSize, maxSize); + const customErrors = validator ? validator(file) : null; + return accepted && sizeMatch && !customErrors; + }); +} + +// React's synthetic events has event.isPropagationStopped, +// but to remain compatibility with other libs (Preact) fall back +// to check event.cancelBubble +export function isPropagationStopped(event) { + if (typeof event.isPropagationStopped === "function") { + return event.isPropagationStopped(); + } else if (typeof event.cancelBubble !== "undefined") { + return event.cancelBubble; + } + return false; +} + +export function isEvtWithFiles(event) { + if (!event.dataTransfer) { + return !!event.target && !!event.target.files; + } + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file + return Array.prototype.some.call( + event.dataTransfer.types, + (type) => type === "Files" || type === "application/x-moz-file" + ); +} + +export function isKindFile(item) { + return typeof item === "object" && item !== null && item.kind === "file"; +} + +// allow the entire document to be a drag target +export function onDocumentDragOver(event) { + event.preventDefault(); +} + +function isIe(userAgent) { + return ( + userAgent.indexOf("MSIE") !== -1 || userAgent.indexOf("Trident/") !== -1 + ); +} + +function isEdge(userAgent) { + return userAgent.indexOf("Edge/") !== -1; +} + +export function isIeOrEdge(userAgent = window.navigator.userAgent) { + return isIe(userAgent) || isEdge(userAgent); +} + +/** + * This is intended to be used to compose event handlers + * They are executed in order until one of them calls `event.isPropagationStopped()`. + * Note that the check is done on the first invoke too, + * meaning that if propagation was stopped before invoking the fns, + * no handlers will be executed. + * + * @param {Function} fns the event hanlder functions + * @return {Function} the event handler to add to an element + */ +export function composeEventHandlers(...fns) { + return (event, ...args) => + fns.some((fn) => { + if (!isPropagationStopped(event) && fn) { + fn(event, ...args); + } + return isPropagationStopped(event); + }); +} + +/** + * canUseFileSystemAccessAPI checks if the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) + * is supported by the browser. + * @returns {boolean} + */ +export function canUseFileSystemAccessAPI() { + return "showOpenFilePicker" in window; +} + +/** + * Convert the `{accept}` dropzone prop to the + * `{types}` option for https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker + * + * @param {AcceptProp} accept + * @returns {{accept: string[]}[]} + */ +export function pickerOptionsFromAccept(accept) { + if (isDefined(accept)) { + const acceptForPicker = Object.entries(accept) + .filter(([mimeType, ext]) => { + let ok = true; + + if (!isMIMEType(mimeType)) { + console.warn( + `Skipped "${mimeType}" because it is not a valid MIME type. Check https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types for a list of valid MIME types.` + ); + ok = false; + } + + if (!Array.isArray(ext) || !ext.every(isExt)) { + console.warn( + `Skipped "${mimeType}" because an invalid file extension was provided.` + ); + ok = false; + } + + return ok; + }) + .reduce( + (agg, [mimeType, ext]) => ({ + ...agg, + [mimeType]: ext, + }), + {} + ); + return [ + { + // description is required due to https://crbug.com/1264708 + description: "Files", + accept: acceptForPicker, + }, + ]; + } + return accept; +} + +/** + * Convert the `{accept}` dropzone prop to an array of MIME types/extensions. + * @param {AcceptProp} accept + * @returns {string} + */ +export function acceptPropAsAcceptAttr(accept) { + if (isDefined(accept)) { + return ( + Object.entries(accept) + .reduce((a, [mimeType, ext]) => [...a, mimeType, ...ext], []) + // Silently discard invalid entries as pickerOptionsFromAccept warns about these + .filter((v) => isMIMEType(v) || isExt(v)) + .join(",") + ); + } + + return undefined; +} + +/** + * Check if v is an exception caused by aborting a request (e.g window.showOpenFilePicker()). + * + * See https://developer.mozilla.org/en-US/docs/Web/API/DOMException. + * @param {any} v + * @returns {boolean} True if v is an abort exception. + */ +export function isAbort(v) { + return ( + v instanceof DOMException && + (v.name === "AbortError" || v.code === v.ABORT_ERR) + ); +} + +/** + * Check if v is a security error. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/DOMException. + * @param {any} v + * @returns {boolean} True if v is a security error. + */ +export function isSecurityError(v) { + return ( + v instanceof DOMException && + (v.name === "SecurityError" || v.code === v.SECURITY_ERR) + ); +} + +/** + * Check if v is a MIME type string. + * + * See accepted format: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers. + * + * @param {string} v + */ +export function isMIMEType(v) { + return ( + v === "audio/*" || + v === "video/*" || + v === "image/*" || + v === "text/*" || + /\w+\/[-+.\w]+/g.test(v) + ); +} + +/** + * Check if v is a file extension. + * @param {string} v + */ +export function isExt(v) { + return /^.*\.[\w]+$/.test(v); +} + +/** + * @typedef {Object.} AcceptProp + */ + +/** + * @typedef {object} FileError + * @property {string} message + * @property {ErrorCode|string} code + */ + +/** + * @typedef {"file-invalid-type"|"file-too-large"|"file-too-small"|"too-many-files"} ErrorCode + */ diff --git a/console/packages/starwhale-ui/src/Upload/types.ts b/console/packages/starwhale-ui/src/Upload/types.ts new file mode 100644 index 0000000000..6088368f49 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/types.ts @@ -0,0 +1,8 @@ +export type UploadFile = File & { + path: string + originFileObj?: any + status: StatusT + percent?: number +} + +type StatusT = 'uploading' | 'error' | 'done' | 'errorMax' | 'errorExist' | 'errorUnknown' diff --git a/console/packages/starwhale-ui/src/Upload/utils.ts b/console/packages/starwhale-ui/src/Upload/utils.ts new file mode 100644 index 0000000000..ed70247715 --- /dev/null +++ b/console/packages/starwhale-ui/src/Upload/utils.ts @@ -0,0 +1,73 @@ +import { UploadFile } from './types' + +export const getUploadType = (file: UploadFile) => { + if (file.type?.startsWith('image')) return 'IMAGE' + if (file.type?.startsWith('video')) return 'VIDEO' + if (file.type?.startsWith('audio')) return 'AUDIO' + if (file.name?.includes('.csv')) return 'CSV' + if (file.name?.includes('.json')) return 'JSON' + if (file.name?.includes('.jsonL')) return 'JSONL' + return '' +} + +export const getUploadName = (file: UploadFile | File | any) => { + if (!file) return '' + if (file.path) return file.path + if (file.originFileObj) return file.originFileObj?.webkitRelativePath ?? file.name + return file.webkitRelativePath ?? file.name +} + +function findMostFrequentType(arr: UploadFile[]) { + const typeCounts: Record = {} + + for (let i = 0; i < arr.length; i++) { + const type = getUploadType(arr[i]) + if (typeCounts[type]) { + typeCounts[type]++ + } else { + typeCounts[type] = 1 + } + } + + let mostFrequentType = '' + let maxCount = 0 + + // eslint-disable-next-line no-restricted-syntax + for (const type in typeCounts) { + if (typeCounts[type] > maxCount) { + mostFrequentType = type + maxCount = typeCounts[type] + } + } + + return mostFrequentType +} + +export const getSignName = (file: UploadFile) => file.originFileObj?.webkitRelativePath ?? file.name + +export const getSignUrls = (fileList: UploadFile[]) => + fileList.reduce((acc, file) => { + if (file.path) { + acc.push(file.path) + } + if (file.originFileObj?.webkitRelativePath) { + acc.push(file.originFileObj?.webkitRelativePath) + } + return acc + }, [] as string[]) + +export function pickAttr(file: File | any) { + return { + ...file, + path: file.path, + originFileObj: file.file, + name: file.name, + size: file.size, + type: file.type, + lastModified: file.lastModified, + lastModifiedDate: file.lastModifiedDate, + webkitRelativePath: file.webkitRelativePath, + } as UploadFile +} + +export { findMostFrequentType } diff --git a/console/src/api/const.ts b/console/src/api/const.ts index 94541a1d88..92a251b9e6 100644 --- a/console/src/api/const.ts +++ b/console/src/api/const.ts @@ -11,6 +11,7 @@ export const Privileges = { 'model.version.revert': true, 'model.version.serve': true, 'dataset.version.revert': true, + 'dataset.create': true, 'evaluation.panel.save': true, 'runtime.image.build': true, 'task.execute': true, diff --git a/console/src/components/Form/form.tsx b/console/src/components/Form/form.tsx index 44c85604ca..612c7d0536 100755 --- a/console/src/components/Form/form.tsx +++ b/console/src/components/Form/form.tsx @@ -106,7 +106,7 @@ export function createShouldUpdate( } const defaultFormItemClassName: Required = { - item: 'rc-form-item', + item: `rc-form-item ${styles.formItem}`, label: 'rc-form-item-label', error: styles.error, touched: 'rc-form-item-touched', @@ -197,8 +197,8 @@ export function createForm({ 'div', { style: { display: 'flex', alignItems: 'center', gap: 4 } }, [ - React.createElement('div', {}, '*'), React.createElement('div', { style: { flexShrink: 0 } }, label), + React.createElement('div', {}, '*'), ] ) } diff --git a/console/src/components/Form/index.module.scss b/console/src/components/Form/index.module.scss index 5f234abbc2..40f427970a 100644 --- a/console/src/components/Form/index.module.scss +++ b/console/src/components/Form/index.module.scss @@ -1,5 +1,16 @@ .formItem { margin-bottom: 10px; + > label { + line-height: 1; + display: flex; + margin-top: 8px; + margin-bottom: 8px; + } + + > div:first { + display: flex; + align-items: 'center'; + } } .formItem:last-child { diff --git a/console/src/components/Shared.tsx b/console/src/components/Shared.tsx index beb837d883..6a4ba7555c 100644 --- a/console/src/components/Shared.tsx +++ b/console/src/components/Shared.tsx @@ -8,7 +8,7 @@ export function Shared({ shared = 0, isTextShow = false }: { shared?: number; is if (shared === 0 && !isTextShow) return null return ( -
+
{shared === 1 && (
+} + +export async function sign(files: string[], pathPrefix?: string): Promise { + const resp = await axios.put('/api/v1/filestorage/signedurl', { + files, + pathPrefix, + }) + return resp.data +} + +export async function deleteFiles(files: string[], pathPrefix?: string): Promise { + const resp = await axios.delete('/api/v1/filestorage/file', { + data: { + // @ts-ignore + files, + pathPrefix, + }, + }) + return resp.data +} + +export async function fetchSignPathPrefix(): Promise { + const resp = await axios.get('/api/v1/filestorage/path/apply?flag=ds-build') + return resp.data +} diff --git a/console/src/domain/dataset/components/DatasetForm.tsx b/console/src/domain/dataset/components/DatasetForm.tsx index 7c2de66c66..ec4f57ec84 100644 --- a/console/src/domain/dataset/components/DatasetForm.tsx +++ b/console/src/domain/dataset/components/DatasetForm.tsx @@ -1,22 +1,68 @@ import React, { useCallback, useEffect, useState } from 'react' import { createForm } from '@/components/Form' -import { Input } from 'baseui/input' import useTranslation from '@/hooks/useTranslation' -import { Button } from 'baseui/button' -import { isModified } from '@/utils' -import { RadioGroup, Radio, ALIGN } from '@starwhale/ui/Radio' -import { ICreateDatasetSchema, IDatasetSchema } from '../schemas/dataset' +import { ICreateDatasetFormSchema, IDatasetSchema } from '../schemas/dataset' +import { createUseStyles } from 'react-jss' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import User from '@/domain/user/components/User' +import { useProject } from '@/domain/project/hooks/useProject' +import { Toggle } from '@starwhale/ui/Select' +import { useHistory } from 'react-router-dom' +import { useQueryArgs } from '@starwhale/core' +import { DraggerUpload } from '@starwhale/ui/Upload' +import Button from '@starwhale/ui/Button' +import Shared from '@/components/Shared' +import Input from '@starwhale/ui/Input' -const { Form, FormItem } = createForm() +const { Form, FormItem, useForm, FormItemLabel } = createForm() + +const useStyles = createUseStyles({ + datasetName: { + display: 'flex', + alignContent: 'stretch', + alignItems: 'flex-start', + gap: '8px', + }, + shared: { + 'display': 'flex', + 'alignItems': 'center', + '& [data-baseweb=form-control-container]': { + marginBottom: '0 !important', + }, + '& > div:first-child': { + marginLeft: '22px', + }, + 'marginBottom': '20px', + }, + row: { + display: 'grid', + gap: 40, + gridTemplateColumns: '660px', + gridTemplateRows: 'minmax(0px, max-content)', + marginBottom: '20px', + }, + upload: { + 'marginBottom': '20px', + '& .ant-upload-btn': { + height: '200px !important', + }, + }, +}) export interface IDatasetFormProps { dataset?: IDatasetSchema - onSubmit: (data: ICreateDatasetSchema) => Promise + onSubmit: (data: ICreateDatasetFormSchema) => Promise } export default function DatasetForm({ dataset, onSubmit }: IDatasetFormProps) { - const [values, setValues] = useState(undefined) - const [importBy, setImportBy] = useState('upload') + const [values, setValues] = useState(undefined) + const { query } = useQueryArgs() + // eslint-disable-next-line react-hooks/exhaustive-deps + const { currentUser } = useCurrentUser() + const { project } = useProject() + const styles = useStyles() + const [form] = useForm() + const history = useHistory() useEffect(() => { if (!dataset) { @@ -47,51 +93,80 @@ export default function DatasetForm({ dataset, onSubmit }: IDatasetFormProps) { const [t] = useTranslation() + const formResetField = useCallback( + (key: any, value: any) => { + form.setFieldsValue({ + [key]: value, + }) + + setValues( + (prev) => + ({ + ...prev, + [key]: value, + } as any) + ) + }, + [form] + ) + + useEffect(() => { + if (query.datasetName) { + formResetField('datasetName', query.datasetName) + } + }, [query.datasetName, formResetField]) + + const disabled = !values?.datasetName || !values?.upload?.type + return ( -
- - - -
- setImportBy(e.currentTarget.value)} + +
+ +
+ / +
+
+ +
+ {project?.name} / +
+
+ - {t('Import from server')} - {t('Upload')} - + +
- {importBy === 'server' && ( - - +
+ {t('Shared')}: + + + - )} - {importBy === 'upload' && ( - // TODO: beauty file upload plugin - - - - {/* {}} - // progressAmount is a number from 0 - 100 which indicates the percent of file transfer completed - /> */} +
+
+ + - )} +
+ -
-
+
+
diff --git a/console/src/domain/dataset/schemas/dataset.tsx b/console/src/domain/dataset/schemas/dataset.tsx index 433405992d..8858f3f09f 100644 --- a/console/src/domain/dataset/schemas/dataset.tsx +++ b/console/src/domain/dataset/schemas/dataset.tsx @@ -24,11 +24,20 @@ export interface IDatasetDetailSchema { export interface IUpdateDatasetSchema { description?: string } - -export interface ICreateDatasetSchema { +export interface ICreateDatasetFormSchema { datasetName: string - zipFile?: FileList - importPath?: string + shared?: number + upload?: { + storagePath?: string + type?: string + } +} + +export interface ICreateDatasetQuerySchema { + datasetId?: string + shared?: number + type: 'IMAGE' | 'VIDEO' | 'AUDIO' + storagePath: string } export interface IDatasetTreeSchema { @@ -38,3 +47,21 @@ export interface IDatasetTreeSchema { shared: number versions: IDatasetTreeVersionSchema[] } + +export interface IDatasetTaskBuildSchema { + id: string + datasetId: string + projectId: string + datasetName: string + status: TaskBuildStatusType + type: string + createTime: number +} + +export enum TaskBuildStatusType { + CREATED = 'CREATED', + UPLOADING = 'UPLOADING', + BUILDING = 'BUILDING', + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', +} diff --git a/console/src/domain/dataset/services/dataset.ts b/console/src/domain/dataset/services/dataset.ts index 6de176e2dd..f0ef0a69fd 100644 --- a/console/src/domain/dataset/services/dataset.ts +++ b/console/src/domain/dataset/services/dataset.ts @@ -1,6 +1,12 @@ import axios from 'axios' import { IListQuerySchema, IListSchema } from '@/domain/base/schemas/list' -import { ICreateDatasetSchema, IDatasetSchema, IDatasetDetailSchema, IDatasetTreeSchema } from '../schemas/dataset' +import { + IDatasetSchema, + IDatasetDetailSchema, + IDatasetTreeSchema, + ICreateDatasetQuerySchema, + IDatasetTaskBuildSchema, +} from '../schemas/dataset' export async function listDatasets(projectId: string, query: IListQuerySchema): Promise> { const resp = await axios.get>(`/api/v1/project/${projectId}/dataset`, { @@ -19,18 +25,15 @@ export async function fetchDatasetTree(projectId: string): Promise { - const bodyFormData = new FormData() - bodyFormData.append('datasetName', data.datasetName) - bodyFormData.append('importPath', data.importPath ?? '') - if (data.zipFile && data.zipFile.length > 0) bodyFormData.append('zipFile', data.zipFile[0] as File) - - const resp = await axios({ - method: 'post', - url: `/api/v1/project/${projectId}/dataset`, - data: bodyFormData, - headers: { 'Content-Type': 'multipart/form-data' }, - }) +export async function createDataset( + projectId: string, + datasetName: string, + data: ICreateDatasetQuerySchema +): Promise { + const resp = await axios.post( + `/api/v1/project/${projectId}/dataset/${datasetName}/build`, + data + ) return resp.data } @@ -38,3 +41,21 @@ export async function removeDataset(projectId: string, datasetId: string): Promi const resp = await axios.delete(`/api/v1/project/${projectId}/dataset/${datasetId}`) return resp.data } + +export async function fetchDatasetBuildList( + projectId: string, + query: Partial & { status: 'CREATED' | 'BUILDING' | 'SUCCESS' | 'FAILED' } +): Promise> { + const resp = await axios.get>( + `/api/v1/project/${projectId}/dataset/build/list`, + { + params: query, + } + ) + return resp.data +} + +export async function fetchDatasetTaskOfflineLogFiles(datasetName: string, taskId: string): Promise { + const resp = await axios.get(`/api/v1/log/offline/dataset/${datasetName}/build/${taskId}`) + return resp.data +} diff --git a/console/src/domain/user/components/User.tsx b/console/src/domain/user/components/User.tsx index c347035922..30bdd50207 100644 --- a/console/src/domain/user/components/User.tsx +++ b/console/src/domain/user/components/User.tsx @@ -3,13 +3,13 @@ import { IUserSchema } from '@user/schemas/user' import Text, { ITextProps } from '@/components/Text' export interface IUserProps { - user: IUserSchema + user?: IUserSchema style?: React.CSSProperties size?: ITextProps['size'] } export default function User({ user, style, size = 'medium' }: IUserProps) { - const { name } = user + const { name } = user || {} return (
{ diff --git a/console/src/i18n/locales.ts b/console/src/i18n/locales.ts index 43342f877d..bde0eb0e01 100644 --- a/console/src/i18n/locales.ts +++ b/console/src/i18n/locales.ts @@ -44,6 +44,14 @@ const basic = { en: 'Docs', zh: '文档', }, + 'form.upload.error.exist': { + en: 'File already exists', + zh: '文件已存在', + }, + 'form.rule.min': { + en: '{{0}} must be at least 3 characters', + zh: '{{0}} 至少需要3个字符', + }, } const dataset = { @@ -83,6 +91,66 @@ const dataset = { en: 'View', zh: '查看', }, + 'dataset.create': { + en: 'Create Dataset', + zh: '创建数据集', + }, + 'dataset.update': { + en: 'Update Dataset', + zh: '更新数据集', + }, + 'dataset.create.title': { + en: 'Dataset Build Task', + zh: '数据集构建任务', + }, + 'dataset.create.owner': { + en: 'Owner', + zh: '拥有者', + }, + 'dataset.create.files': { + en: 'Dataset Files', + zh: '数据集文件', + }, + 'dataset.create.type': { + en: 'Dataset Type', + zh: '数据集类型', + }, + 'dataset.create.build.desc': { + en: '{{0}} Datasets are building', + zh: '{{0}} 个数据集正在构建', + }, + 'dataset.create.upload.desc': { + en: 'Drag or click to upload files', + zh: '拖拽或点击上传文件', + }, + 'dataset.create.upload.total.desc': { + en: '{{0}} files, total size {{1}}', + zh: '{{0}} 个文件,共 {{1}}', + }, + 'dataset.create.upload.error.type': { + zh: '文件主类型异常, 建议格式 image/video/audio/csv/json/jsonl .', + en: 'File main type is invalid, suggest format image/video/audio/csv/json/jsonl.', + }, + 'dataset.create.upload.error.desc': { + en: 'the following files have an invalid status: ', + zh: '以下文件上传状态异常: ', + }, + 'dataset.create.upload.error.exist': { + en: 'File already exists', + zh: '文件已存在', + }, + 'dataset.create.upload.error.unknown': { + en: 'File already exists', + zh: '文件异常', + }, + 'dataset.create.upload.error.max': { + en: 'File size exceeds the maximum limit', + zh: '文件大小超过最大限制', + }, + 'dataset.create.upload.drag.desc': { + en: "Drag 'n' drop some files here, or click to select files", + zh: '拖拽文件到此处,或点击选择文件', + }, } const runtime = { @@ -1224,6 +1292,10 @@ const locales0 = { en: 'Files', zh: '文件', }, + 'Directory': { + en: 'Directory', + zh: '目录', + }, 'Version ID': { en: 'Version ID', zh: '版本标识', diff --git a/console/src/pages/Dataset/DatasetBuildList.tsx b/console/src/pages/Dataset/DatasetBuildList.tsx new file mode 100644 index 0000000000..7533359c2f --- /dev/null +++ b/console/src/pages/Dataset/DatasetBuildList.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react' +import Card from '@/components/Card' +import { usePage } from '@/hooks/usePage' +import { formatTimestampDateTime } from '@/utils/datetime' +import useTranslation from '@/hooks/useTranslation' +import Table from '@/components/Table/index' +import { useParams, useLocation } from 'react-router-dom' +import { StyledLink } from 'baseui/link' +import _ from 'lodash' +import qs from 'qs' +import { Modal, ModalHeader, ModalBody } from 'baseui/modal' +import ExecutorForm from '@job/components/ExecutorForm' +import { fetchDatasetBuildList } from '@/domain/dataset/services/dataset' +import { useQuery } from 'react-query' + +export interface ITaskListCardProps { + header: React.ReactNode + onAction?: (type: string, value: any) => void +} + +export default function DatasetBuildList({ header, onAction }: ITaskListCardProps) { + const [page] = usePage() + const { jobId, projectId } = useParams<{ jobId: string; projectId: string }>() + const location = useLocation() + const id = qs.parse(location.search, { ignoreQueryPrefix: true })?.id ?? '' + const query = { ...page } + const tasksInfo = useQuery(`fetchDatasetBuildList:${projectId}:${qs.stringify(query)}`, () => + fetchDatasetBuildList(projectId, query as any) + ) + + const [t] = useTranslation() + const [currentTaskExecutor, setCurrentTaskExecutor] = React.useState('') + + useEffect(() => { + if (id && tasksInfo.data?.list) { + const taskInfo = tasksInfo.data?.list.find((task) => task.id === id) + if (taskInfo) + onAction?.('viewlog', { + ...taskInfo, + }) + } + }, [tasksInfo.isSuccess, tasksInfo.data, id, onAction]) + + return ( + + {header} + { + return [ + task.id, + task.createTime && formatTimestampDateTime(task.createTime), + task.datasetName, + task.type, + task.status, +

+ { + // eslint-disalbe-next-line no-unused-expressions + const trDom = e.currentTarget.closest('tr') + const trDoms = trDom?.parentElement?.children + _.forEach(trDoms, (d) => { + d?.classList.remove('tr--selected') + }) + trDom?.classList.add('tr--selected') + + onAction?.('viewlog', { + ...task, + }) + }} + > + {t('View Log')} + +

, + ] + }) ?? [] + } + paginationProps={{ + start: tasksInfo.data?.pageNum, + count: tasksInfo.data?.pageSize, + total: tasksInfo.data?.total, + afterPageChange: () => { + tasksInfo.refetch() + }, + }} + /> + + setCurrentTaskExecutor('')} + overrides={{ + Dialog: { + style: { + width: '50vw', + height: '50vh', + display: 'flex', + flexDirection: 'column', + }, + }, + }} + closeable + animate + autoFocus + > + {t('job.task.executor')} + + + + + + ) +} diff --git a/console/src/pages/Dataset/DatasetBuildListCard.tsx b/console/src/pages/Dataset/DatasetBuildListCard.tsx new file mode 100644 index 0000000000..01d86f44d6 --- /dev/null +++ b/console/src/pages/Dataset/DatasetBuildListCard.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useMemo, useState } from 'react' +import useTranslation from '@/hooks/useTranslation' +import Card from '@/components/Card' +import { getToken } from '@/api' +import { toaster } from 'baseui/toast' +import { BusyPlaceholder } from '@starwhale/ui' +import DatasetTaskBuildList from './DatasetBuildList' +import { fetchDatasetTaskOfflineLogFiles } from '@/domain/dataset/services/dataset' +import { IDatasetTaskBuildSchema, TaskBuildStatusType } from '@/domain/dataset/schemas/dataset' + +const ComplexToolbarLogViewer = React.lazy(() => import('@/components/LogViewer/LogViewer')) + +export interface IScrollProps { + scrollTop: number + scrollHeight: number + clientHeight: number +} + +export default function DatasetBuildListCard() { + const [t] = useTranslation() + const [currentTask, setCurrentTask] = useState(undefined) + const [, setExpanded] = useState(false) + const [currentLogFiles, setCurrentLogFiles] = useState>({}) + + const onAction = useCallback(async (type, task: IDatasetTaskBuildSchema) => { + // console.log(task) + setCurrentTask(task) + const files: Record = {} + const key = [task?.datasetName, task?.id].join('@') + + if ([TaskBuildStatusType.BUILDING].includes(task.status)) { + files[key] = 'ws' + } else { + const data = await fetchDatasetTaskOfflineLogFiles(task?.datasetName, task?.id) + files[key] = data ?? '' + } + + if (Object.keys(files).length === 0) { + toaster.negative('No logs collected for this task', { autoHideDuration: 2000 }) + } + + setCurrentLogFiles({ + ...files, + }) + + setExpanded(true) + }, []) + + const currentOnlineLogUrl = useMemo(() => { + return `${window.location.protocol === 'http:' ? 'ws:' : 'wss:'}//${ + window.location.host + }/api/v1/log/online/dataset/${currentTask?.datasetName}/build/${currentTask?.id}?Authorization=${getToken()}` + }, [currentTask]) + + const sources = React.useMemo(() => { + return Object.entries(currentLogFiles).map(([fileName, content]) => { + return { + id: fileName, + type: '', + data: content.startsWith('ws') ? '' : content, + ws: content.startsWith('ws') ? currentOnlineLogUrl : undefined, + } + }) + }, [currentLogFiles, currentOnlineLogUrl]) + + return ( +
+ + + + }> + + + +
+ ) +} diff --git a/console/src/pages/Dataset/DatasetListCard.tsx b/console/src/pages/Dataset/DatasetListCard.tsx index ffa30314f2..cb45a86e92 100644 --- a/console/src/pages/Dataset/DatasetListCard.tsx +++ b/console/src/pages/Dataset/DatasetListCard.tsx @@ -1,19 +1,21 @@ -import React, { useCallback, useState } from 'react' +import React from 'react' import Card from '@/components/Card' -import { createDataset } from '@dataset/services/dataset' import { usePage } from '@/hooks/usePage' -import { ICreateDatasetSchema } from '@dataset/schemas/dataset' -import DatasetForm from '@dataset/components/DatasetForm' import { formatTimestampDateTime } from '@/utils/datetime' import useTranslation from '@/hooks/useTranslation' -import { Modal, ModalHeader, ModalBody } from 'baseui/modal' import Table from '@/components/Table' import { useHistory, useParams } from 'react-router-dom' import { useFetchDatasets } from '@dataset/hooks/useFetchDatasets' import { TextLink } from '@/components/Link' -import { Button } from '@starwhale/ui' +import { Button, IconFont } from '@starwhale/ui' import Alias from '@/components/Alias' import { MonoText } from '@/components/Text' +import { WithCurrentAuth } from '@/api/WithAuth' +import User from '@/domain/user/components/User' +import { useQuery } from 'react-query' +import { fetchDatasetBuildList } from '@/domain/dataset/services/dataset' +import qs from 'qs' +import Text from '@starwhale/ui/Text' export default function DatasetListCard() { const [page] = usePage() @@ -21,73 +23,120 @@ export default function DatasetListCard() { const history = useHistory() const datasetsInfo = useFetchDatasets(projectId, page) - const [isCreateDatasetOpen, setIsCreateDatasetOpen] = useState(false) - const handleCreateDataset = useCallback( - async (data: ICreateDatasetSchema) => { - await createDataset(projectId, data) - await datasetsInfo.refetch() - setIsCreateDatasetOpen(false) - }, - [datasetsInfo, projectId] - ) const [t] = useTranslation() + const query = { status: 'BUILDING', ...page } + + const datasetBuildList = useQuery(`fetchDatasetBuildList:${projectId}:${qs.stringify(query)}`, () => + fetchDatasetBuildList(projectId, query as any) + ) + + const buildCount = datasetBuildList.data?.list?.length ?? 0 + return ( - -
{ - return [ - - {dataset.name} - , - {dataset.version?.name ?? '-'}, - , - // dataset.owner && , - dataset.createdTime && formatTimestampDateTime(dataset.createdTime), - , - ] - }) ?? [] + <> + + + } - paginationProps={{ - start: datasetsInfo.data?.pageNum, - count: datasetsInfo.data?.pageSize, - total: datasetsInfo.data?.total, - afterPageChange: () => { - datasetsInfo.refetch() - }, + > +
{ + return [ + + {dataset.name} + , + {dataset.version?.name ?? '-'}, + , + dataset.owner && , + dataset.createdTime && formatTimestampDateTime(dataset.createdTime), +
+
, + ] + }) ?? [] + } + paginationProps={{ + start: datasetsInfo.data?.pageNum, + count: datasetsInfo.data?.pageSize, + total: datasetsInfo.data?.total, + afterPageChange: () => { + datasetsInfo.refetch() + }, + }} + /> + +
- setIsCreateDatasetOpen(false)} - closeable - animate - autoFocus > - {t('create sth', [t('Dataset')])} - - - - - + +
+ ) } diff --git a/console/src/pages/Project/DatasetNewCard.tsx b/console/src/pages/Project/DatasetNewCard.tsx new file mode 100644 index 0000000000..425bbc09e0 --- /dev/null +++ b/console/src/pages/Project/DatasetNewCard.tsx @@ -0,0 +1,42 @@ +import React, { useCallback } from 'react' +import Card from '@/components/Card' +import useTranslation from '@/hooks/useTranslation' +import { useHistory, useParams } from 'react-router-dom' +import { useQueryArgs } from '@starwhale/core' +import { createDataset } from '@/domain/dataset/services/dataset' +import { ICreateDatasetFormSchema } from '@/domain/dataset/schemas/dataset' + +const DatasetForm = React.lazy( + () => import(/* webpackChunkName: "datasetForm" */ '@/domain/dataset/components/DatasetForm') +) + +export default function DatasetNewCard() { + const { projectId, datasetId } = useParams<{ projectId: string; datasetId: string }>() + const { query } = useQueryArgs() + const [t] = useTranslation() + const history = useHistory() + + const handleSubmit = useCallback( + async (data: ICreateDatasetFormSchema) => { + if (!projectId || !data.datasetName || !data.upload?.storagePath || !data.upload?.type) { + return + } + await createDataset(projectId, data.datasetName, { + datasetId, + type: data.upload?.type as any, + shared: data.shared, + storagePath: data.upload?.storagePath, + }) + history.push(`/projects/${projectId}/datasets`) + }, + [projectId, datasetId, history] + ) + + return ( + + + + + + ) +} diff --git a/console/src/pages/Project/ProjectSidebar.tsx b/console/src/pages/Project/ProjectSidebar.tsx index 7492b61c2a..00303ffe2e 100644 --- a/console/src/pages/Project/ProjectSidebar.tsx +++ b/console/src/pages/Project/ProjectSidebar.tsx @@ -40,7 +40,7 @@ export default function ProjectSidebar({ style }: IComposedSidebarProps) { { title: t('Datasets'), path: `/projects/${projectId}/datasets`, - activePathPattern: /\/(datasets)\/?/, + activePathPattern: /\/(datasets|new_dataset)\/?/, icon: , }, { diff --git a/console/src/routes.tsx b/console/src/routes.tsx index 95b82a9829..d8eb04c541 100644 --- a/console/src/routes.tsx +++ b/console/src/routes.tsx @@ -14,6 +14,7 @@ import ModelVersionListCard from '@/pages/Model/ModelVersionListCard' import DatasetVersionListCard from '@/pages/Dataset/DatasetVersionListCard' import DatasetOverviewLayout from '@/pages/Dataset/DatasetOverviewLayout' import JobNewCard from '@/pages/Project/JobNewCard' +import DatasetNewCard from '@/pages/Project/DatasetNewCard' import ApiHeader from '@/api/ApiHeader' import JobTasks from '@/pages/Job/JobTasks' import JobWidgetResults from '@/pages/Job/JobWidgetResults' @@ -52,6 +53,7 @@ import TrashListCard from '@/pages/Trash/TrashListCard' import OnlineEval from '@/pages/Project/OnlineEval' import { getAuthedRoutes, getUnauthedRoutes } from './routesUtils' import EvaluationListResult from './pages/Evaluation/EvaluationListResult' +import DatasetBuildListCard from './pages/Dataset/DatasetBuildListCard' // const JobDAG = React.lazy(() => import('@/pages/Job/JobDAG')) @@ -178,6 +180,15 @@ const Routes = () => { {/* datasets */} + + + + + { /> +