diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index c36f66645..20df0fc5c 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -8,6 +8,7 @@ sed -e "s|\"facilityName\": \".*\"|\"facilityName\": \"$FACILITY_NAME\"|" \ -e "s|\"idsUrl\": \".*\"|\"idsUrl\": \"$IDS_URL\"|" \ -e "s|\"apiUrl\": \".*\"|\"apiUrl\": \"$API_URL\"|" \ -e "s|\"downloadApiUrl\": \".*\"|\"downloadApiUrl\": \"$DOWNLOAD_API_URL\"|" \ + -e "s|\"uploadUrl\": \".*\"|\"uploadUrl\": \"$UPLOAD_URL\"|" \ -e "s|\"pluginHost\": \".*\"|\"pluginHost\": \"/datagateway-dataview\"|" \ /usr/local/apache2/htdocs/datagateway-dataview/datagateway-dataview-settings.json > "$TEMPFILE" diff --git a/packages/datagateway-common/package.json b/packages/datagateway-common/package.json index bf8f05482..68b79f959 100644 --- a/packages/datagateway-common/package.json +++ b/packages/datagateway-common/package.json @@ -12,6 +12,15 @@ "@emotion/styled": "11.11.0", "@mui/x-date-pickers": "6.11.2", "@types/lodash.debounce": "4.0.6", + "@uppy/core": "^3.7.1", + "@uppy/dashboard": "^3.7.1", + "@uppy/drag-drop": "^3.0.3", + "@uppy/file-input": "^3.0.4", + "@uppy/form": "^3.0.3", + "@uppy/golden-retriever": "^3.1.1", + "@uppy/progress-bar": "^3.0.4", + "@uppy/react": "^3.2.1", + "@uppy/tus": "^3.4.0", "axios": "1.6.1", "connected-react-router": "6.9.1", "date-fns": "2.30.0", @@ -103,7 +112,7 @@ }, "jest": { "transformIgnorePatterns": [ - "node_modules/(?!axios)" + "node_modules/(?!(axios|@uppy|nanoid|exifr|p-queue|p-timeout)/)" ], "collectCoverageFrom": [ "src/**/*.{tsx,ts,js,jsx}", diff --git a/packages/datagateway-common/src/__mocks__/axios.ts b/packages/datagateway-common/src/__mocks__/axios.ts index 9705993d1..903813628 100644 --- a/packages/datagateway-common/src/__mocks__/axios.ts +++ b/packages/datagateway-common/src/__mocks__/axios.ts @@ -6,7 +6,9 @@ const requests = { get: jest.fn(() => Promise.resolve({ data: {} })), post: jest.fn(() => Promise.resolve({ data: {} })), delete: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), CancelToken: axios.CancelToken, + isAxiosError: axios.isAxiosError, }; export default requests; diff --git a/packages/datagateway-common/src/api/index.test.tsx b/packages/datagateway-common/src/api/index.test.tsx index 6b1b699d5..a7ac9e191 100644 --- a/packages/datagateway-common/src/api/index.test.tsx +++ b/packages/datagateway-common/src/api/index.test.tsx @@ -12,6 +12,7 @@ import { useSort, usePushCurrentTab, useUpdateView, + refreshSession, } from './index'; import { FiltersType, @@ -1074,4 +1075,20 @@ describe('generic api functions', () => { ); }); }); + + it('sends a PUT request to refresh the session', () => { + const apiUrl = 'https://example.com/api'; + + refreshSession(apiUrl); + + expect(axios.put).toHaveBeenCalledWith( + `${apiUrl}/sessions`, + {}, + { + headers: { + Authorization: `Bearer null`, + }, + } + ); + }); }); diff --git a/packages/datagateway-common/src/api/index.tsx b/packages/datagateway-common/src/api/index.tsx index d0b2638e2..51de5d047 100644 --- a/packages/datagateway-common/src/api/index.tsx +++ b/packages/datagateway-common/src/api/index.tsx @@ -819,3 +819,15 @@ export const useCustomFilterCount = ( // @ts-ignore return useQueries(queryConfigs); }; + +export const refreshSession = (apiUrl: string): void => { + axios.put( + `${apiUrl}/sessions`, + {}, + { + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + } + ); +}; diff --git a/packages/datagateway-common/src/index.css b/packages/datagateway-common/src/index.css deleted file mode 100644 index d07361807..000000000 --- a/packages/datagateway-common/src/index.css +++ /dev/null @@ -1,19 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -@keyframes rotate { - to { - transform: rotateZ(360deg); - } -} diff --git a/packages/datagateway-common/src/index.tsx b/packages/datagateway-common/src/index.tsx index 965e33ba7..14d9c5431 100644 --- a/packages/datagateway-common/src/index.tsx +++ b/packages/datagateway-common/src/index.tsx @@ -2,7 +2,6 @@ // import React from 'react'; // import ReactDOM from 'react-dom'; // import App from './App'; -// import './index.css'; import { StateType } from './state/app.types'; @@ -66,6 +65,7 @@ export type { CartProps } from './views/viewCartButton.component'; export { default as ViewButton } from './views/viewButton.component'; export { default as ClearFiltersButton } from './views/clearFiltersButton.component'; export { default as DownloadButton } from './views/downloadButton.component'; +export { default as UploadButton } from './views/uploadButton.component'; export { default as SelectionAlert } from './views/selectionAlert.component'; export { default as ISISDatafileDetailsPanel } from './detailsPanels/isis/datafileDetailsPanel.component'; diff --git a/packages/datagateway-common/src/preloader/__snapshots__/preloader.component.test.tsx.snap b/packages/datagateway-common/src/preloader/__snapshots__/preloader.component.test.tsx.snap index 91c6aa27e..74bf9e490 100644 --- a/packages/datagateway-common/src/preloader/__snapshots__/preloader.component.test.tsx.snap +++ b/packages/datagateway-common/src/preloader/__snapshots__/preloader.component.test.tsx.snap @@ -18,22 +18,22 @@ exports[`Preloader component renders when the site is loading 1`] = ` style="box-sizing: border-box; padding: 10px 0px;" >
diff --git a/packages/datagateway-common/src/preloader/preloader.component.tsx b/packages/datagateway-common/src/preloader/preloader.component.tsx index 7b6bd5729..f0080a840 100644 --- a/packages/datagateway-common/src/preloader/preloader.component.tsx +++ b/packages/datagateway-common/src/preloader/preloader.component.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box } from '@mui/material'; +import { Box, keyframes, styled } from '@mui/material'; const colors = ['#8C4799', '#1D4F91', '#C34613', '#008275', '#63666A']; const innerRadius = 140; @@ -11,11 +11,13 @@ interface PreloaderProps { children: React.ReactNode; } -interface SpinnerStyle { - [id: string]: string | number; -} +const rotate = keyframes` + to { + transform: rotateZ(360deg); + } +`; -const spinnerStyle = (index: number): SpinnerStyle => { +const StyledI = styled('i')(({ index }: { index: number }) => { const size = innerRadius + index * 2 * (border + spacing); return { @@ -32,7 +34,10 @@ const spinnerStyle = (index: number): SpinnerStyle => { width: size, marginTop: -size / 2, marginLeft: -size / 2, - animationName: 'rotate', + animation: `${rotate} 3s infinite cubic-bezier(.09, ${0.3 * index}, ${ + 0.12 * index + }, .03)`, + animationName: `${rotate}`, animationIterationCount: 'infinite', animationDuration: '3s', animationTimingFunction: `cubic-bezier(.09, ${0.3 * index}, ${ @@ -41,7 +46,7 @@ const spinnerStyle = (index: number): SpinnerStyle => { transformOrigin: '50% 100% 0', boxSizing: 'border-box', }; -}; +}); const Preloader: React.FC = (props: PreloaderProps) => (
@@ -59,21 +64,21 @@ const Preloader: React.FC = (props: PreloaderProps) => ( }} >
-
- - - - - -
+ + + + + +
Loading... diff --git a/packages/datagateway-common/src/setupTests.tsx b/packages/datagateway-common/src/setupTests.tsx index dd4f896aa..c638d406f 100644 --- a/packages/datagateway-common/src/setupTests.tsx +++ b/packages/datagateway-common/src/setupTests.tsx @@ -90,6 +90,7 @@ export const createReactQueryWrapper = ( icatUrl: 'https://example.com/icat', idsUrl: 'https://example.com/ids', downloadApiUrl: 'https://example.com/topcat', + uploadUrl: 'https://example.com/upload', }, }, }; diff --git a/packages/datagateway-common/src/state/actions/actions.types.tsx b/packages/datagateway-common/src/state/actions/actions.types.tsx index 4f3d4e6d6..a744e134f 100644 --- a/packages/datagateway-common/src/state/actions/actions.types.tsx +++ b/packages/datagateway-common/src/state/actions/actions.types.tsx @@ -277,6 +277,7 @@ export interface URLs { apiUrl: string; downloadApiUrl: string; icatUrl: string; + uploadUrl?: string; } export interface PluginRoute { diff --git a/packages/datagateway-common/src/views/__snapshots__/uploadDialog.component.test.tsx.snap b/packages/datagateway-common/src/views/__snapshots__/uploadDialog.component.test.tsx.snap new file mode 100644 index 000000000..f0a2b5a13 --- /dev/null +++ b/packages/datagateway-common/src/views/__snapshots__/uploadDialog.component.test.tsx.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload dialog component Datafile renders correctly 1`] = ``; + +exports[`Upload dialog component Dataset renders correctly 1`] = ``; diff --git a/packages/datagateway-common/src/views/uploadButton.component.test.tsx b/packages/datagateway-common/src/views/uploadButton.component.test.tsx new file mode 100644 index 000000000..add37130a --- /dev/null +++ b/packages/datagateway-common/src/views/uploadButton.component.test.tsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import UploadButton, { UploadButtonProps } from './uploadButton.component'; +import configureStore from 'redux-mock-store'; +import { initialState as dGCommonInitialState } from '../state/reducers/dgcommon.reducer'; +import { StateType } from '../state/app.types'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { render, type RenderResult, screen } from '@testing-library/react'; +import { UserEvent } from '@testing-library/user-event/setup/setup'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../api/datafiles'); +jest.mock('../api/datasets'); +jest.mock('../api/investigations'); + +describe('Generic upload button', () => { + const mockStore = configureStore([thunk]); + let state: StateType; + let user: UserEvent; + + function renderComponent(props: UploadButtonProps): RenderResult { + const store = mockStore(state); + return render( + + + + + + + + ); + } + + beforeEach(() => { + user = userEvent.setup(); + state = JSON.parse( + JSON.stringify({ + dgdataview: {}, + dgcommon: { + ...dGCommonInitialState, + urls: { + ...dGCommonInitialState.urls, + idsUrl: 'https://www.example.com/ids', + }, + }, + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('text variant', () => { + it('renders correctly dataset', async () => { + renderComponent({ + entityType: 'dataset', + entityId: 1, + variant: 'text', + }); + expect( + await screen.findByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + expect(await screen.findByText('Upload Datafile')).toBeInTheDocument(); + }); + + it('renders correctly investigation', async () => { + renderComponent({ + entityType: 'investigation', + entityId: 1, + variant: 'text', + }); + expect( + await screen.findByRole('button', { name: 'buttons.upload_dataset' }) + ).toBeInTheDocument(); + expect(await screen.findByText('Upload Dataset')).toBeInTheDocument(); + }); + + it('opens upload dialog when clicked', async () => { + renderComponent({ + entityType: 'dataset', + entityId: 1, + variant: 'text', + }); + + const uploadButton = await screen.findByRole('button', { + name: 'buttons.upload_datafile', + }); + + // Trigger the onClick handler directly + uploadButton.click(); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('renders a tooltip when hovered', async () => { + renderComponent({ + entityType: 'dataset', + entityId: 1, + variant: 'text', + }); + + await user.hover( + await screen.findByRole('button', { + name: 'buttons.upload_datafile', + }) + ); + + expect( + await screen.findByRole('tooltip', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + }); + }); + + describe('full text variant', () => { + it('renders correctly', async () => { + renderComponent({ + entityType: 'datafile', + entityId: 1, + variant: 'text', + }); + expect( + await screen.findByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + expect( + await screen.findByText('buttons.upload_datafile') + ).toBeInTheDocument(); + }); + + it('opens upload dialog when clicked', async () => { + renderComponent({ + entityType: 'datafile', + entityId: 1, + variant: 'text', + }); + + const uploadButton = await screen.findByRole('button', { + name: 'buttons.upload_datafile', + }); + + // Trigger the onClick handler directly + uploadButton.click(); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('renders a tooltip when hovered', async () => { + renderComponent({ + entityType: 'datafile', + entityId: 1, + variant: 'text', + }); + + await user.hover( + await screen.findByRole('button', { + name: 'buttons.upload_datafile', + }) + ); + + expect( + await screen.findByRole('tooltip', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + }); + }); + + describe('icon variant', () => { + it('renders correctly', async () => { + renderComponent({ + entityType: 'dataset', + entityId: 1, + variant: 'icon', + }); + + expect( + await screen.findByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + + // The text should not be rendered + expect( + screen.queryByText('buttons.upload_datafile') + ).not.toBeInTheDocument(); + }); + + it('opens upload dialog when clicked', async () => { + renderComponent({ + entityType: 'dataset', + entityId: 1, + variant: 'icon', + }); + + const uploadButton = await screen.findByRole('button', { + name: 'buttons.upload_datafile', + }); + + // Trigger the onClick handler directly + uploadButton.click(); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('renders a tooltip when hovered', async () => { + renderComponent({ + entityType: 'investigation', + entityId: 1, + variant: 'icon', + }); + + await user.hover( + await screen.findByRole('button', { + name: 'buttons.upload_dataset', + }) + ); + + expect( + await screen.findByRole('tooltip', { name: 'buttons.upload_dataset' }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/datagateway-common/src/views/uploadButton.component.tsx b/packages/datagateway-common/src/views/uploadButton.component.tsx new file mode 100644 index 000000000..d693ab516 --- /dev/null +++ b/packages/datagateway-common/src/views/uploadButton.component.tsx @@ -0,0 +1,106 @@ +import { + Button, + ButtonProps, + IconButton, + IconButtonProps, +} from '@mui/material'; +import { Publish } from '@mui/icons-material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyledTooltip } from '../arrowtooltip.component'; +import UploadDialog from './uploadDialog.component'; + +export interface UploadButtonProps { + entityType: 'investigation' | 'dataset' | 'datafile'; + entityId: number; + variant?: 'text' | 'outlined' | 'contained' | 'icon'; +} + +const UploadButton: React.FC = ( + props: UploadButtonProps +) => { + const { entityType, entityId, variant } = props; + + const [t] = useTranslation(); + + const [showUploadDialog, setShowUploadDialog] = React.useState(false); + const [showTooltip, setShowTooltip] = React.useState(false); + + const BaseUploadButton = React.useCallback( + (props: ButtonProps & IconButtonProps): React.ReactElement => { + const OurButton = (props: ButtonProps): React.ReactElement => ( + + ); + const OurIconButton = (props: IconButtonProps): React.ReactElement => ( + + + + ); + const ButtonToUse = variant === 'icon' ? OurIconButton : OurButton; + return ( + + ); + }, + [variant, t, entityId, entityType] + ); + + return ( + <> + setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + title={t( + entityType === 'investigation' + ? 'buttons.upload_dataset' + : 'buttons.upload_datafile' + )} + id={`tooltip-${entityId}`} + placement={variant === 'icon' ? 'left' : 'bottom'} + arrow + > + + { + setShowTooltip(false); + setShowUploadDialog(true); + }} + /> + + + setShowUploadDialog(false)} + /> + + ); +}; + +export default UploadButton; diff --git a/packages/datagateway-common/src/views/uploadDialog.component.test.tsx b/packages/datagateway-common/src/views/uploadDialog.component.test.tsx new file mode 100644 index 000000000..7aa0b7c99 --- /dev/null +++ b/packages/datagateway-common/src/views/uploadDialog.component.test.tsx @@ -0,0 +1,577 @@ +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import UploadDialog, { + checkNameExists, + beforeFileAdded, + postProcessor, +} from './uploadDialog.component'; +import { Provider } from 'react-redux'; +import { combineReducers, createStore } from 'redux'; +import dGCommonReducer from '../state/reducers/dgcommon.reducer'; +import { StateType } from '../state/app.types'; +import axios from 'axios'; +import { readSciGatewayToken } from '../parseTokens'; +import Uppy, { UppyFile } from '@uppy/core'; + +// TODO: see if we can remove this +// eslint-disable-next-line import/no-extraneous-dependencies +// import { fireEvent } from '@testing-library/dom'; + +jest.mock('../api'); + +const createUppyInstance = (): Uppy => { + // jest mock the uppy instance + const uppy = new Uppy(); + uppy.info = jest.fn(); + uppy.getFiles = jest.fn().mockReturnValue([]); + uppy.setFileState = jest.fn(); + return uppy; +}; + +const createUppyFile = (name: string): UppyFile => { + // jest mock the uppy file + const file = { + id: 'test-id', + name, + type: 'text/plain', + size: 100, + meta: { name: name }, + data: { lastModified: 123456 }, + } as UppyFile; + + return file; +}; + +describe('Upload dialog component', () => { + describe('Dataset', () => { + let queryClient: QueryClient; + + const createWrapper = (): RenderResult => + render( + >({ dgcommon: dGCommonReducer }) + )} + > + + + + + ); + + beforeEach(() => { + queryClient = new QueryClient(); + setLogger({ + log: console.log, + warn: console.warn, + error: () => undefined, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { asFragment } = createWrapper(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders a cancel and upload buttons', () => { + createWrapper(); + expect( + screen.getByRole('button', { name: 'cancel' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'upload' }) + ).toBeInTheDocument(); + }); + + it('renders a name and description text field', () => { + createWrapper(); + expect( + screen.getByRole('textbox', { name: 'upload.name' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('textbox', { name: 'upload.description' }) + ).toBeInTheDocument(); + }); + + it('renders a file upload dashboard', () => { + createWrapper(); + expect(screen.getByLabelText('Uppy Dashboard')).toBeInTheDocument(); + }); + + it('Closes dialog when cancel button is clicked', async () => { + const closeFunction = jest.fn(); + + render( + >({ dgcommon: dGCommonReducer }) + )} + > + + + + + ); + + await userEvent.click(screen.getByRole('button', { name: 'cancel' })); + + expect(closeFunction).toHaveBeenCalled(); + }); + }); + + describe('Datafile', () => { + let queryClient: QueryClient; + + const createWrapper = (): RenderResult => + render( + >({ dgcommon: dGCommonReducer }) + )} + > + + + + + ); + + beforeEach(() => { + queryClient = new QueryClient(); + setLogger({ + log: console.log, + warn: console.warn, + error: () => undefined, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { asFragment } = createWrapper(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders a cancel and upload buttons', () => { + createWrapper(); + expect( + screen.getByRole('button', { name: 'cancel' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'upload' }) + ).toBeInTheDocument(); + }); + + it("doesn't render a name and description text field", () => { + createWrapper(); + expect( + screen.queryByRole('textbox', { name: 'upload.name' }) + ).not.toBeInTheDocument(); + + expect( + screen.queryByRole('textbox', { name: 'upload.description' }) + ).not.toBeInTheDocument(); + }); + + it('renders a file upload dashboard', () => { + createWrapper(); + expect(screen.getByLabelText('Uppy Dashboard')).toBeInTheDocument(); + }); + + it('Closes dialog when cancel button is clicked', async () => { + const closeFunction = jest.fn(); + + render( + >({ dgcommon: dGCommonReducer }) + )} + > + + + + + ); + + await userEvent.click(screen.getByRole('button', { name: 'cancel' })); + + expect(closeFunction).toHaveBeenCalled(); + }); + }); + + it('checks if datafile name exists in the dataset', async () => { + const apiUrl = 'https://example.com/api'; + const name = 'test.txt'; + const datasetId = 1; + + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + name: { eq: name }, + }) + ); + params.append( + 'where', + JSON.stringify({ + 'dataset.id': { eq: datasetId }, + }) + ); + + const axiosGetSpy = jest.spyOn(axios, 'get'); + axiosGetSpy.mockResolvedValueOnce({}); + + const result = await checkNameExists(apiUrl, name, 'datafile', datasetId); + + expect(axios.get).toHaveBeenCalledWith(`${apiUrl}/datafiles/findone`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + expect(result).toBe(true); + }); + + it('returns false if datafile name does not exist in the dataset', async () => { + const apiUrl = 'https://example.com/api'; + const name = 'test.txt'; + const datasetId = 1; + + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + name: { eq: name }, + }) + ); + params.append( + 'where', + JSON.stringify({ + 'dataset.id': { eq: datasetId }, + }) + ); + + const axiosGetSpy = jest.spyOn(axios, 'get'); + const mockError = { + isAxiosError: true, + response: { status: 404 }, + }; + axiosGetSpy.mockRejectedValueOnce(mockError); + + const result = await checkNameExists(apiUrl, name, 'datafile', datasetId); + + expect(axios.get).toHaveBeenCalledWith(`${apiUrl}/datafiles/findone`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + expect(result).toBe(false); + }); + + it('throws an error if an unexpected error occurs', async () => { + const apiUrl = 'https://example.com/api'; + const name = 'test.txt'; + const datasetId = 1; + + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + name: { eq: name }, + }) + ); + params.append( + 'where', + JSON.stringify({ + 'dataset.id': { eq: datasetId }, + }) + ); + + const axiosGetSpy = jest.spyOn(axios, 'get'); + axiosGetSpy.mockRejectedValueOnce(new Error('Unexpected error')); + + await expect( + checkNameExists(apiUrl, name, 'datafile', datasetId) + ).rejects.toThrowError('Unexpected error'); + + expect(axios.get).toHaveBeenCalledWith(`${apiUrl}/datafiles/findone`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + }); + + it('checks if dataset name exists in the investigation', async () => { + const apiUrl = 'https://example.com/api'; + const name = 'test'; + const investigationId = 1; + + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + name: { eq: name }, + }) + ); + params.append( + 'where', + JSON.stringify({ + 'investigation.id': { eq: investigationId }, + }) + ); + + const axiosGetSpy = jest.spyOn(axios, 'get'); + axiosGetSpy.mockResolvedValueOnce({}); + + const result = await checkNameExists( + apiUrl, + name, + 'dataset', + investigationId + ); + + expect(axios.get).toHaveBeenCalledWith(`${apiUrl}/datasets/findone`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + expect(result).toBe(true); + }); + + it('checks if the file has a correct extension', () => { + const uppy = createUppyInstance(); + + // correct extension + const file1 = createUppyFile('test.txt'); + + expect(beforeFileAdded(uppy, file1)).toBe(true); + expect(uppy.info).not.toHaveBeenCalled(); + + // incorrect extension + const file2 = createUppyFile('test.xml'); + + expect(beforeFileAdded(uppy, file2)).toBe(false); + expect(uppy.info).toHaveBeenCalledWith( + '.xml files are not allowed', + 'error', + 5000 + ); + }); + + it('checks if the file is a duplicate', () => { + const uppy = createUppyInstance(); + const file1 = createUppyFile('test.txt'); + const file2 = createUppyFile('test.txt'); + + uppy.getFiles = jest.fn().mockReturnValue([file1]); + + const result = beforeFileAdded(uppy, file2); + + expect(result).toBe(false); + expect(uppy.info).toHaveBeenCalledWith( + 'File named "test.txt" is already in the upload queue', + 'error', + 5000 + ); + }); + + it('checks if the file in the queue is a ghost', () => { + const uppy = createUppyInstance(); + const file1 = createUppyFile('test.txt'); + const file2 = createUppyFile('test.txt'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (file2 as any).isGhost = true; + + uppy.getFiles = jest.fn().mockReturnValue([file2]); + + const result = beforeFileAdded(uppy, file1); + + expect(result).toBe(true); + expect(uppy.info).not.toHaveBeenCalled(); + expect(file2.size).toBe(0); + }); + + it('should post processes dataset upload', async () => { + const file = createUppyFile('test.txt'); + file.meta = { name: 'test.txt' }; + file.response = { + body: { id: 1 }, + status: 200, + uploadURL: 'example.com/upload/123', + }; + + // simulate the failed file + const file2 = createUppyFile('test2.txt'); + file2.meta = { name: 'test2.txt' }; + file2.response = undefined; + + const uppy = createUppyInstance(); + uppy.getFiles = jest.fn().mockReturnValue([file, file2]); + + const refObject = { current: null }; + + axios.post = jest + .fn() + .mockResolvedValueOnce({ status: 200, data: { datasetId: 123 } }); + + const queryClient = new QueryClient(); + queryClient.invalidateQueries = jest.fn(); + + await postProcessor( + uppy, + refObject, + 'investigation', + 'testName', + 'testDescription', + 1, + 'example.com', + queryClient + ); + + // should only commit the successful file + expect(axios.post).toHaveBeenCalledWith( + 'example.com/commit', + { + datafiles: [ + { + name: 'test.txt', + size: 100, + lastModified: 123456, + url: 'example.com/upload/123', + }, + ], + dataset: { + datasetDescription: 'testDescription', + datasetName: 'testName', + investigationId: 1, + }, + }, + { headers: { authorization: 'Bearer null' } } + ); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith(['dataset']); + expect(refObject.current).toBe(123); + }); + + it('should post processes datafile upload', async () => { + const file = createUppyFile('test.txt'); + file.meta = { name: 'test.txt' }; + file.response = { + body: { id: 1 }, + status: 200, + uploadURL: 'example.com/upload/123', + }; + + // simulate the failed file + const file2 = createUppyFile('test2.txt'); + file2.meta = { name: 'test2.txt' }; + file2.response = undefined; + + const uppy = createUppyInstance(); + uppy.getFiles = jest.fn().mockReturnValue([file, file2]); + + const refObject = { current: 123 }; + + axios.post = jest + .fn() + .mockResolvedValueOnce({ status: 200, data: { datasetId: 123 } }); + + const queryClient = new QueryClient(); + queryClient.invalidateQueries = jest.fn(); + + await postProcessor( + uppy, + refObject, + 'datafile', + 'testName', + 'testDescription', + 123, + 'example.com', + queryClient + ); + + // should only commit the successful file + expect(axios.post).toHaveBeenCalledWith( + 'example.com/commit', + { + datafiles: [ + { + datasetId: 123, + name: 'test.txt', + size: 100, + lastModified: 123456, + url: 'example.com/upload/123', + }, + ], + }, + { headers: { authorization: 'Bearer null' } } + ); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith(['datafile']); + expect(refObject.current).toBe(123); + }); + + it('should not commit if no files are successful', async () => { + const file = createUppyFile('test.txt'); + file.meta = { name: 'test.txt' }; + file.response = undefined; + + const uppy = createUppyInstance(); + uppy.getFiles = jest.fn().mockReturnValue([file]); + + const refObject = { current: null }; + + axios.post = jest + .fn() + .mockResolvedValueOnce({ status: 200, data: { datasetId: 123 } }); + + const queryClient = new QueryClient(); + queryClient.invalidateQueries = jest.fn(); + + await postProcessor( + uppy, + refObject, + 'investigation', + 'testName', + 'testDescription', + 1, + 'example.com', + queryClient + ); + + expect(axios.post).not.toHaveBeenCalled(); + expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + expect(refObject.current).toBe(null); + }); +}); diff --git a/packages/datagateway-common/src/views/uploadDialog.component.tsx b/packages/datagateway-common/src/views/uploadDialog.component.tsx new file mode 100644 index 000000000..96244ab26 --- /dev/null +++ b/packages/datagateway-common/src/views/uploadDialog.component.tsx @@ -0,0 +1,536 @@ +import React from 'react'; +import { + Button, + Dialog, + DialogTitle, + DialogActions, + DialogContent as MuiDialogContent, + Grid, + styled, + TextField, + Typography, + Box, + useTheme, +} from '@mui/material'; + +import Uppy, { UppyFile } from '@uppy/core'; +import { Dashboard } from '@uppy/react'; +import Tus from '@uppy/tus'; +import GoldenRetriever from '@uppy/golden-retriever'; +import '@uppy/core/dist/style.min.css'; +import '@uppy/drag-drop/dist/style.min.css'; +import '@uppy/dashboard/dist/style.min.css'; + +import { readSciGatewayToken } from '../parseTokens'; +import { refreshSession } from '../api'; +import { useTranslation } from 'react-i18next'; +import { useQueryClient } from 'react-query'; +import { useSelector } from 'react-redux'; +import { StateType } from '../state/app.types'; +import axios from 'axios'; + +const DialogContent = styled(MuiDialogContent)(({ theme }) => ({ + padding: theme.spacing(2), +})); + +export const checkNameExists = async ( + apiUrl: string, + name: string, + entityType: 'datafile' | 'dataset', + entityId: number +): Promise => { + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + name: { eq: name }, + }) + ); + params.append( + 'where', + JSON.stringify({ + [`${entityType === 'datafile' ? 'dataset' : 'investigation'}.id`]: { + eq: entityId, + }, + }) + ); + + try { + await axios.get(`${apiUrl}/${entityType}s/findone`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + return true; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return false; + } + throw error; + } +}; + +export const beforeFileAdded = (uppy: Uppy, currentFile: UppyFile): boolean => { + const isCorrectExtension = [ + '.xml', + '.exe', + '.dll', + '.bat', + '.sh', + '.sqlite', + '.js', + '.vbs', + '.PHP', + '.wmv', + '.mp3', + '.flv', + ].some((ext) => currentFile.name.endsWith(ext)); + + const isDuplicate = uppy.getFiles().some((file) => { + // have to use any here as isGhost is not in the Uppy file type (?) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return file.name === currentFile.name && !(file as any).isGhost; + }); + + if (isDuplicate) { + uppy.info( + `File named "${currentFile.name}" is already in the upload queue`, + 'error', + 5000 + ); + return false; + } + + if (isCorrectExtension) { + uppy.info( + `.${currentFile.name + .split('.') + .pop() + ?.toLowerCase()} files are not allowed`, + 'error', + 5000 + ); + return false; + } else { + // TODO: is there another way to do this? + // Workaround for Uppy bug where it doubles the size of restored files + uppy.getFiles().forEach((file) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (file.id === currentFile.id && (file as any).isGhost) { + file.size = 0; + } + }); + return true; + } +}; + +export const postProcessor = async ( + uppy: Uppy, + datasetId: React.MutableRefObject, + entityType: 'investigation' | 'dataset' | 'datafile', + uploadName: string, + uploadDescription: string, + entityId: number, + uploadUrl: string | undefined, + queryClient: ReturnType +): Promise => { + // check if the files have been uploaded + const files = uppy.getFiles(); + const uploadedFiles = files.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (file) => file?.response !== undefined && !(file as any).isCommited + ); + if (uploadedFiles.length > 0) { + let params = {}; + if (entityType === 'investigation' && datasetId.current === null) { + params = { + dataset: { + datasetName: uploadName, + datasetDescription: uploadDescription, + investigationId: entityId, + }, + datafiles: uploadedFiles.map((file) => { + return { + name: file.name, + url: file.response?.uploadURL, + size: file.size, + lastModified: (file.data as File).lastModified, + }; + }), + }; + } else { + params = { + datafiles: uploadedFiles.map((file) => { + return { + name: file.name, + url: file.response?.uploadURL, + size: file.size, + datasetId: datasetId.current ? datasetId.current : entityId, + lastModified: (file.data as File).lastModified, + }; + }), + }; + } + return axios + .post(`${uploadUrl}/commit`, params, { + headers: { + authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + .then((response) => { + uploadedFiles.forEach((file) => { + uppy.setFileState(file.id, { isCommited: true }); + }); + + datasetId.current = response.data.datasetId ?? null; + if (entityType === 'datafile') { + queryClient.invalidateQueries(['datafile']); + } else { + queryClient.invalidateQueries(['dataset']); + } + }); + } + + return Promise.resolve(); +}; + +interface UploadDialogProps { + entityType: 'investigation' | 'dataset' | 'datafile'; + entityId: number; + open: boolean; + + setClose: () => void; +} + +const UploadDialog: React.FC = ( + props: UploadDialogProps +) => { + const { entityType, entityId, open, setClose } = props; + const queryClient = useQueryClient(); + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); + const [uploadDisabled, setUploadDisabled] = React.useState(true); + const [textInputDisabled, setTextInputDisabled] = + React.useState(false); + + const datasetId = React.useRef(null); + + const [uploadName, setUploadName] = React.useState(''); + const [uploadDescription, setUploadDescription] = React.useState(''); + + const [uppy] = React.useState(() => + new Uppy({ + // debug: true, + id: + `${ + entityType === 'investigation' ? 'investigation' : 'dataset' + }-${props.entityId?.toString()}` ?? 'null', + autoProceed: false, + restrictions: { + maxTotalFileSize: 5368709120, + }, + // TODO: ask Alex/users about ux of this + allowMultipleUploadBatches: false, + onBeforeFileAdded: (currentFile) => { + // TODO: why can't we return the result of beforeFileAdded directly? + const addFile = beforeFileAdded(uppy, currentFile); + return addFile; + }, + onBeforeUpload: (files) => { + // Refresh the session before uploading so that the session doesn't expire + // while the user is idly uploading large files + refreshSession(apiUrl); + return true; + }, + }) + .on('file-added', async (file) => { + let prevUploadDisabled = false; + setUploadDisabled((oldUploadDisabled) => { + prevUploadDisabled = oldUploadDisabled; + return true; + }); + if (entityType !== 'investigation') { + const fileExists = await checkNameExists( + apiUrl, + file.name, + 'datafile', + entityId + ).catch((error) => { + uppy.info(error.message, 'error', 5000); + return true; + }); + + if (fileExists) { + uppy.info( + `File "${file.name}" already exists in this dataset`, + 'error', + 5000 + ); + uppy.removeFile(file.id); + setUploadDisabled(prevUploadDisabled); + return; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!uppy.getFiles().some((file) => (file as any).isGhost)) { + setUploadDisabled(false); + } else { + setUploadDisabled(true); + } + }) + .on('file-removed', () => { + if (uppy.getFiles().length === 0) { + setUploadDisabled(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } else if (!uppy.getFiles().some((file) => (file as any).isGhost)) { + setUploadDisabled(false); + } + }) + .on('error', (error) => { + uppy.info(error.message, 'error', 5000); + }) + // // TODO: is this needed here? + // .on('complete', (result) => { + // if (result.failed.length === 0) { + // datasetId.current = null; + // } + // }) + .use(Tus, { + endpoint: `${uploadUrl}/upload/`, + uploadDataDuringCreation: true, + headers: { + authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + ); + + React.useEffect(() => { + const postProcessorWrapper = (): Promise => + postProcessor( + uppy, + datasetId, + entityType, + uploadName, + uploadDescription, + entityId, + uploadUrl, + queryClient + ); + uppy.addPostProcessor(postProcessorWrapper); + + return () => { + uppy.removePostProcessor(postProcessorWrapper); + }; + }, [ + entityId, + entityType, + queryClient, + uploadDescription, + uploadName, + uploadUrl, + uppy, + ]); + + // TODO: check it doesn't break anything + React.useEffect(() => { + if ( + open && + uppy.getFiles().length > 0 && + uppy.getState().recoveredState && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + !uppy.getFiles().some((file) => (file as any).isGhost) + ) { + setUploadDisabled(false); + } + }, [open, uppy]); + + // TODO: investigate why this causes tests to fail + // Temporary fix (?): only use GoldenRetriever if indexedDB is available + React.useEffect(() => { + /* istanbul ignore if */ + if ( + process.env.NODE_ENV === 'development' || + process.env.REACT_APP_E2E_TESTING + ) { + const preProcessor = async (): Promise => { + const files = uppy.getFiles(); + files.forEach((file) => { + if (file.name === 'fail') { + uppy.setFileState(file.id, { + data: null, + name: 'notfail', + }); + file.meta.name = `not${file.meta.name}`; + } else if (file.name === 'notfail') { + uppy.setFileState(file.id, { + data: new File(['test'], 'notfail'), + }); + } + }); + }; + uppy.addPreProcessor(preProcessor); + } + + if (window.indexedDB) { + uppy.use(GoldenRetriever); + } + }, [uppy]); + + const [t] = useTranslation(); + const theme = useTheme(); + + const dialogClose = (_event?: unknown, reason?: string): void => { + if (reason !== 'backdropClick') { + uppy.cancelAll({ reason: 'user' }); + setUploadName(''); + setUploadDescription(''); + setClose(); + datasetId.current = null; + setTextInputDisabled(false); + setUploadDisabled(true); + } + }; + + return ( + + + + {entityType === 'investigation' + ? t('buttons.upload_dataset') + : t('buttons.upload_datafile')} + + + + + {entityType === 'investigation' && ( + + + { + setUploadName(e.target.value as string); + }} + disabled={textInputDisabled} + required + /> + + { + setUploadDescription(e.target.value as string); + }} + multiline + rows={16} + disabled={textInputDisabled} + /> + + + )} + + { + dialogClose(); + }} + /> + + + + + + + + + + + + + + ); +}; + +export default UploadDialog; diff --git a/packages/datagateway-dataview/craco.config.js b/packages/datagateway-dataview/craco.config.js index 2489e78d3..a21c78cb2 100644 --- a/packages/datagateway-dataview/craco.config.js +++ b/packages/datagateway-dataview/craco.config.js @@ -17,6 +17,12 @@ module.exports = { webpackConfig.optimization.runtimeChunk = false; } + // TODO: should we do this, or should we import uppy styles from CDN instead? + const cssRule = webpackConfig.module.rules + .find((r) => r.oneOf) + .oneOf.find((r) => r.test.toString() === '/\\.css$/'); + cssRule.use = ['style-loader', 'css-loader']; + return webpackConfig; }, }, diff --git a/packages/datagateway-dataview/cypress/e2e/dlsUpload.cy.ts b/packages/datagateway-dataview/cypress/e2e/dlsUpload.cy.ts new file mode 100644 index 000000000..b2d9abefe --- /dev/null +++ b/packages/datagateway-dataview/cypress/e2e/dlsUpload.cy.ts @@ -0,0 +1,795 @@ +const datasets: number[] = []; +const datafiles: number[] = []; + +describe.skip('DLS Upload functionality', () => { + beforeEach(() => { + cy.intercept('**/upload/').as('upload'); + cy.intercept('**/commit').as('commit'); + cy.intercept('**/datasets/?*').as('deleteDataset'); + cy.intercept('**/datafiles/?*').as('deleteDatafile'); + cy.login({ + username: 'root', + password: 'pw', + mechanism: 'simple', + }); + }); + + it('should correctly render all upload buttons', () => { + cy.visit('/browse/proposal/INVESTIGATION%201/investigation/'); + + cy.get('[aria-label="Upload Dataset to Visit"]').should('be.visible'); + + cy.get('[aria-label="page view Display as cards"]').click(); + cy.get('[aria-label="Upload Dataset to Visit"]') + .contains('Upload Dataset') + .should('be.visible'); + + cy.get('[aria-label="card-title"]').first().click(); + cy.get('[aria-label="Upload Datafiles to Dataset"]') + .contains('Upload Datafile') + .should('be.visible'); + + cy.get('[aria-label="page view Display as table"]').click(); + cy.get('[aria-label="Upload Datafiles to Dataset"]').should('be.visible'); + + cy.get('[data-testid="dls-datasets-table-title"]').first().click(); + cy.get('[aria-label="Upload Datafiles to Dataset"]') + .contains('Upload Datafiles to Dataset') + .should('be.visible'); + }); + + describe('Datasets', () => { + beforeEach(() => { + cy.visit('/browse/proposal/INVESTIGATION%201/investigation/'); + }); + + afterEach(() => { + cy.removeUploads(datasets, datafiles).then(() => { + datasets.length = 0; + datafiles.length = 0; + }); + }); + + it('should render dataset upload dialog', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[role="dialog"]').should('be.visible'); + + cy.contains('Upload Dataset to Visit').should('be.visible'); + cy.get('[aria-label="upload"]').should('be.disabled'); + cy.get('[aria-label="close"]').should('be.visible'); + cy.get('[aria-label="cancel"]').should('be.visible'); + cy.get('[aria-label="Uppy Dashboard"]').should('be.visible'); + cy.get('input[id="upload-name"]').should('be.visible'); + cy.get('textarea[id="upload-description"]').should('be.visible'); + + cy.get('[aria-label="close"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + }); + + it('should be able to upload a dataset with a single file', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').should('be.enabled'); + + cy.get('[aria-label="upload"]').click(); + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + datasets.push(commit.response?.body.datasetId); + }); + + cy.get('[aria-label="upload"]').should('be.disabled'); + + cy.get('button').contains('Done').click(); + cy.get('[data-testid="dls-visits-table-visitId"]').first().click(); + + cy.get('[aria-label="Test dataset"]').should('be.visible').click(); + cy.get('[aria-label="datafile.txt"]').should('be.visible'); + }); + + it('should be able to upload a dataset with multiple files', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + // add a file via drag-drop + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + // TODO: add a file via file explorer + cy.get('[aria-label="Add more files"]').click(); + cy.get('button').contains('browse files').should('be.visible'); + cy.get('button').contains('browse folders').should('be.visible'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.json', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').should('be.enabled'); + + cy.get('[aria-label="upload"]').click(); + cy.wait(['@upload', '@upload']); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + datasets.push(commit.response?.body.datasetId); + }); + + cy.get('[aria-label="upload"]').should('be.disabled'); + cy.get('input[id="upload-name"]').should('be.disabled'); + cy.get('textarea[id="upload-description"]').should('be.disabled'); + + // should not allow any more files to be added + cy.get('[aria-label="Add more files"]').should('not.exist'); + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.json', + { action: 'drag-drop' } + ); + cy.contains('Cannot add more files'); + + cy.get('button').contains('Done').click(); + cy.get('[data-testid="dls-visits-table-visitId"]').first().click(); + + cy.get('[aria-label="Test dataset"]').should('be.visible').click(); + cy.get('[aria-label="datafile.txt"]').should('be.visible'); + cy.get('[aria-label="datafile.json"]').should('be.visible'); + }); + + it('should not allow to upload .xml files', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'datafile.xml', + mimeType: 'application/xml', + }, + { action: 'drag-drop' } + ); + + cy.contains('.xml files are not allowed'); + }); + + it('should not be able to upload a dataset without a name', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').click(); + cy.contains('Dataset name cannot be empty'); + }); + + it('should not be able to upload a dataset with a name that already exists', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Dataset 1'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').click(); + cy.contains('Dataset "Dataset 1" already exists in this investigation'); + }); + + it('should not allow to add a file with the same name', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.contains('File named "datafile.txt" is already in the upload queue'); + }); + + it('should reset the form when the cancel button is clicked', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="cancel"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[id="upload-name"]').should('have.value', ''); + cy.get('[id="upload-description"]').should('have.value', ''); + }); + + it('should not reset the form when the close button is clicked', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="close"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[id="upload-name"]').should('have.value', 'Test dataset'); + cy.get('[id="upload-description"]').should( + 'have.value', + 'Test description' + ); + cy.contains('datafile.txt'); + }); + + it('should allow to retry a failed upload', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'fail', + mimeType: '', + }, + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').click(); + + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + datasets.push(commit.response?.body.datasetId); + }); + + cy.contains('Failed to upload notfail'); + + cy.contains('Retry').click(); + + cy.contains('datafile.txt').should('be.visible'); + cy.contains('notfail').should('be.visible'); + + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + }); + }); + + it('should recover files after a browser crash', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + cy.get('[id="upload-name"]').type('Test dataset'); + cy.get('[id="upload-description"]').type('Test description'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.contains('datafile.txt').should('be.visible'); + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'be.visible' + ); + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'have.class', + 'is-ghost' + ); + cy.contains('Session restored').should('be.visible'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'not.have.class', + 'is-ghost' + ); + cy.get('input[id="upload-name"]').type('Test dataset'); + cy.get('[aria-label="upload"]').click(); + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + datasets.push(commit.response?.body.datasetId); + }); + }); + + it('disables the upload button correctly', () => { + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + + // Upload button should be disabled when no files are selected + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be disabled if invalid file is selected + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'datafile.xml', + mimeType: 'application/xml', + }, + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled if valid file is selected + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be disabled if all files are removed + cy.get('[aria-label="Remove file"]').click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled when files are restored (without ghost files) + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.json', + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be disabled when files are restored (with ghost files) + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled when ghost files are removed + cy.get('[aria-label="Remove file"]').first().click({ force: true }); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be enabled when ghost file is reselcted + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('[aria-label="Upload Dataset to Visit"]').first().click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + }); + }); + + describe('Datafiles', () => { + beforeEach(() => { + cy.visit( + '/browse/proposal/INVESTIGATION%201/investigation/1/dataset/13/datafile' + ); + }); + + afterEach(() => { + cy.removeUploads(datasets, datafiles).then(() => { + datasets.length = 0; + datafiles.length = 0; + }); + }); + + it('should render datafile upload dialog', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[role="dialog"]').should('be.visible'); + + cy.contains('Upload Datafiles to Dataset').should('be.visible'); + cy.get('[aria-label="upload"]').should('be.disabled'); + cy.get('[aria-label="close"]').should('be.visible'); + cy.get('[aria-label="cancel"]').should('be.visible'); + cy.get('[aria-label="Uppy Dashboard"]').should('be.visible'); + cy.get('input[id="upload-name"]').should('not.exist'); + cy.get('textarea[id="upload-description"]').should('not.exist'); + + cy.get('[aria-label="close"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + }); + + it('should be able to upload a single datafile', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').should('be.enabled'); + + cy.get('[aria-label="upload"]').click(); + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + commit.response?.body.datafiles.forEach((datafile: number) => { + datafiles.push(datafile); + }); + }); + + cy.get('[aria-label="upload"]').should('be.disabled'); + + cy.get('button').contains('Done').click(); + cy.get('[aria-label="datafile.txt"]').should('be.visible'); + }); + + it('should be able to upload multiple datafiles', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.json', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').should('be.enabled'); + + cy.get('[aria-label="upload"]').click(); + cy.wait(['@upload', '@upload']); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + commit.response?.body.datafiles.forEach((datafile: number) => { + datafiles.push(datafile); + }); + }); + + cy.get('[aria-label="upload"]').should('be.disabled'); + + cy.get('button').contains('Done').click(); + cy.get('[aria-label="datafile.txt"]').should('be.visible'); + cy.get('[aria-label="datafile.json"]').should('be.visible'); + }); + + it('should not allow to upload .xml files', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'datafile.xml', + mimeType: 'application/xml', + }, + { action: 'drag-drop' } + ); + + cy.contains('.xml files are not allowed'); + }); + + it('should not allow to add a file with the same name', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.contains('File named "datafile.txt" is already in the upload queue'); + }); + + it('should reset the form when the cancel button is clicked', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="cancel"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.contains('datafile.txt').should('not.exist'); + }); + + it('should not reset the form when the close button is clicked', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="close"]').click(); + cy.get('[role="dialog"]').should('not.exist'); + + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.contains('datafile.txt').should('be.visible'); + }); + + it('should allow to retry a failed upload', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'fail', + mimeType: '', + }, + { action: 'drag-drop' } + ); + + cy.get('[aria-label="upload"]').click(); + + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + commit.response?.body.datafiles.forEach((datafile: number) => { + datafiles.push(datafile); + }); + }); + + cy.contains('Failed to upload notfail'); + + // upload button should be disabled + cy.get('[aria-label="upload"]').should('be.disabled'); + + cy.contains('Retry').click(); + + cy.contains('datafile.txt').should('be.visible'); + cy.contains('notfail').should('be.visible'); + + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + commit.response?.body.datafiles.forEach((datafile: number) => { + datafiles.push(datafile); + }); + }); + }); + + it('should recover files after a browser crash', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.contains('datafile.txt').should('be.visible'); + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'be.visible' + ); + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'have.class', + 'is-ghost' + ); + cy.contains('Session restored').should('be.visible'); + + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + + cy.get('[id="uppy_uppy-bigfile/txt-1e-text/plain-20971520-0"]').should( + 'not.have.class', + 'is-ghost' + ); + + cy.get('[aria-label="upload"]').click(); + cy.wait('@upload'); + cy.wait('@commit').then((commit) => { + expect(commit.response?.statusCode).to.equal(201); + commit.response?.body.datafiles.forEach((datafile: number) => { + datafiles.push(datafile); + }); + }); + + cy.get('[aria-label="close"]').click(); + cy.get('[aria-label="datafile.txt"]').should('be.visible'); + cy.get('[aria-label="bigfile.txt"]').should('be.visible'); + }); + + it('disables the upload button correctly', () => { + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + + // Upload button should be disabled when no files are selected + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be disabled if invalid file is selected + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.from('file contents'), + fileName: 'datafile.xml', + mimeType: 'application/xml', + }, + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled if valid file is selected + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.txt', + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be disabled if all files are removed + cy.get('[aria-label="Remove file"]').click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled when files are restored (without ghost files) + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + 'cypress/fixtures/datafile.json', + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be disabled when files are restored (with ghost files) + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + + // should be enabled when ghost files are removed + cy.get('[aria-label="Remove file"]').first().click({ force: true }); + cy.get('[aria-label="upload"]').should('be.enabled'); + + // should be enabled when ghost file is reselcted + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.reload(true); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('[aria-label="Upload Datafiles to Dataset"]').first().click(); + cy.get('[aria-label="upload"]').should('be.disabled'); + cy.get('[aria-label="Uppy Dashboard"]').selectFile( + { + contents: Cypress.Buffer.alloc(20 * 1024 * 1024), + fileName: 'bigfile.txt', + mimeType: '', + lastModified: 0, + }, + { action: 'drag-drop' } + ); + cy.get('[aria-label="upload"]').should('be.enabled'); + }); + }); +}); diff --git a/packages/datagateway-dataview/cypress/support/commands.js b/packages/datagateway-dataview/cypress/support/commands.js index c38ae30cb..0cf003bad 100644 --- a/packages/datagateway-dataview/cypress/support/commands.js +++ b/packages/datagateway-dataview/cypress/support/commands.js @@ -121,3 +121,28 @@ Cypress.Commands.add('isScrolledTo', { prevSubject: true }, (element) => { ); }); }); + +// Clean up the uploaded files and datasets +Cypress.Commands.add('removeUploads', (datasets, datafiles) => { + cy.request('datagateway-dataview-settings.json').then((response) => { + const settings = response.body; + datasets.forEach((datasetId) => { + cy.request({ + method: 'DELETE', + url: `${settings.apiUrl}/datasets/${datasetId}`, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + }); + datafiles.forEach((datafileId) => { + cy.request({ + method: 'DELETE', + url: `${settings.apiUrl}/datafiles/${datafileId}`, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + }); + }); +}); diff --git a/packages/datagateway-dataview/cypress/support/index.d.ts b/packages/datagateway-dataview/cypress/support/index.d.ts index 004d508dc..7229ec8a1 100644 --- a/packages/datagateway-dataview/cypress/support/index.d.ts +++ b/packages/datagateway-dataview/cypress/support/index.d.ts @@ -9,5 +9,9 @@ declare namespace Cypress { user?: string ): Cypress.Chainable; clearDownloadCart(): Cypress.Chainable; + removeUploads( + datasets: number[], + datafiles: number[] + ): Cypress.Chainable; } } diff --git a/packages/datagateway-dataview/package.json b/packages/datagateway-dataview/package.json index 64ecec321..b0a2c2c34 100644 --- a/packages/datagateway-dataview/package.json +++ b/packages/datagateway-dataview/package.json @@ -84,7 +84,7 @@ }, "jest": { "transformIgnorePatterns": [ - "node_modules/(?!axios)" + "node_modules/(?!(axios|@uppy|nanoid|exifr|p-queue|p-timeout)/)" ], "collectCoverageFrom": [ "src/**/*.{tsx,ts,js,jsx}", diff --git a/packages/datagateway-dataview/public/datagateway-dataview-settings.example.json b/packages/datagateway-dataview/public/datagateway-dataview-settings.example.json index 8b48c10eb..fec129244 100644 --- a/packages/datagateway-dataview/public/datagateway-dataview-settings.example.json +++ b/packages/datagateway-dataview/public/datagateway-dataview-settings.example.json @@ -4,6 +4,7 @@ "features": {}, "idsUrl": "example.ids.url", "apiUrl": "example.api.url", + "uploadUrl": "example.upload.url", "downloadApiUrl": "example.download.api.url", "selectAllSetting": true, "helpSteps": [ @@ -97,6 +98,10 @@ "target": ".tour-dataview-download", "content": "Click on this button to download this specific item" }, + { + "target": ".tour-dataview-upload", + "content": "Click on this button to upload to this specific item" + }, { "target": ".tour-dataview-citation-formatter", "content": "Here you can format the citation for this data. Use the dropdown to select the citation format and click the button to copy the citation" diff --git a/packages/datagateway-dataview/public/res/default.json b/packages/datagateway-dataview/public/res/default.json index 6f40ef68a..92730af37 100644 --- a/packages/datagateway-dataview/public/res/default.json +++ b/packages/datagateway-dataview/public/res/default.json @@ -247,10 +247,16 @@ "parent_selected_tooltip": "Selection disabled, parent entity has already been added to cart", "remove_from_cart": "Remove from selection", "download": "Download", + "upload_datafile": "Upload Datafiles to Dataset", + "upload_dataset": "Upload Dataset to Visit", "unable_to_download_tooltip": "Unable to download - this item is empty", "preview": "Preview datafile", "preview_tooltip": "Preview this datafile" }, + "upload": { + "name": "Name", + "description": "Description" + }, "advanced_filters": { "show": "Show Advanced Filters", "hide": "Hide Advanced Filters", diff --git a/packages/datagateway-dataview/src/App.css b/packages/datagateway-dataview/src/App.css deleted file mode 100644 index c6cb7523f..000000000 --- a/packages/datagateway-dataview/src/App.css +++ /dev/null @@ -1,29 +0,0 @@ -.App-logo { - animation: App-logo-spin infinite 20s linear; - height: 40vmin; - pointer-events: none; -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/packages/datagateway-dataview/src/App.tsx b/packages/datagateway-dataview/src/App.tsx index ccd84b7f1..3c3cf5514 100644 --- a/packages/datagateway-dataview/src/App.tsx +++ b/packages/datagateway-dataview/src/App.tsx @@ -21,7 +21,6 @@ import { batch, connect, Provider } from 'react-redux'; import { AnyAction, applyMiddleware, compose, createStore, Store } from 'redux'; import { createLogger } from 'redux-logger'; import thunk, { ThunkDispatch } from 'redux-thunk'; -import './App.css'; import { saveApiUrlMiddleware } from './page/idCheckFunctions'; import PageContainer from './page/pageContainer.component'; import { configureApp } from './state/actions'; diff --git a/packages/datagateway-dataview/src/index.css b/packages/datagateway-dataview/src/index.css index d07361807..896cb5bbe 100644 --- a/packages/datagateway-dataview/src/index.css +++ b/packages/datagateway-dataview/src/index.css @@ -1,19 +1,8 @@ body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + margin: 0; /* can remove this when we're using CssBaseline (React 18 branch) */ } -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -@keyframes rotate { - to { - transform: rotateZ(360deg); - } +/* TODO: Temporary/remove */ +.uppy-StatusBar-actionBtn--upload { + display: none !important; } diff --git a/packages/datagateway-dataview/src/page/pageContainer.component.test.tsx b/packages/datagateway-dataview/src/page/pageContainer.component.test.tsx index ab09c7cd9..c47a40419 100644 --- a/packages/datagateway-dataview/src/page/pageContainer.component.test.tsx +++ b/packages/datagateway-dataview/src/page/pageContainer.component.test.tsx @@ -100,6 +100,11 @@ describe('PageContainer - Tests', () => { }); user = userEvent.setup(); + dGCommonInitialState.urls = { + ...dGCommonInitialState.urls, + uploadUrl: 'https://example.com/upload', + }; + delete window.location; window.location = new URL(`http://localhost/`); @@ -282,6 +287,18 @@ describe('PageContainer - Tests', () => { ).toBeDisabled(); }); + it('display upload datafile button on dls datafile table', async () => { + history.replace('/browse/proposal/1/investigation/1/dataset/25/datafile'); + renderComponent(); + + // wait for the page to load + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + }); + }); + it('display filter warning on datafile table', async () => { history.replace('/browse/investigation/1/dataset/25/datafile'); (checkInvestigationId as jest.Mock).mockResolvedValueOnce(true); diff --git a/packages/datagateway-dataview/src/page/pageContainer.component.tsx b/packages/datagateway-dataview/src/page/pageContainer.component.tsx index 163e606c8..15fb133c9 100644 --- a/packages/datagateway-dataview/src/page/pageContainer.component.tsx +++ b/packages/datagateway-dataview/src/page/pageContainer.component.tsx @@ -23,6 +23,7 @@ import { useUpdateQueryParam, ViewButton, ClearFiltersButton, + UploadButton, } from 'datagateway-common'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,6 +42,8 @@ import TranslatedHomePage from './translatedHomePage.component'; import DoiRedirect from './doiRedirect.component'; import RoleSelector from '../views/roleSelector.component'; import { useIsFetching, useQueryClient } from 'react-query'; +import { StateType } from 'datagateway-common'; +import { useSelector } from 'react-redux'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getTablePaperStyle = ( @@ -653,6 +656,10 @@ const DataviewPageContainer: React.FC = () => { } }; + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + return ( { /> )} /> + { + const datasetId = match.params.datasetId as string; + return ( + uploadUrl && ( + + ) + ); + }} + /> > => { idsUrl: settingsResult['idsUrl'], apiUrl: settingsResult['apiUrl'], downloadApiUrl: settingsResult['downloadApiUrl'], + uploadUrl: settingsResult['uploadUrl'], icatUrl: '', // we currently don't need icatUrl in dataview so just pass empty string for now }) ); diff --git a/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.test.tsx b/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.test.tsx index dd65fcc15..2c2df015c 100644 --- a/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.test.tsx +++ b/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.test.tsx @@ -65,6 +65,11 @@ describe('DLS Datasets - Card View', () => { history = createMemoryHistory(); user = userEvent.setup(); + dGCommonInitialState.urls = { + ...dGCommonInitialState.urls, + uploadUrl: 'https://example.com/upload', + }; + mockStore = configureStore([thunk]); state = JSON.parse( JSON.stringify({ @@ -187,6 +192,9 @@ describe('DLS Datasets - Card View', () => { expect( await screen.findByRole('button', { name: 'buttons.add_to_cart' }) ).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); }); it('renders fine with incomplete data', () => { diff --git a/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.tsx b/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.tsx index d13b455ea..fc4689c3d 100644 --- a/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.tsx +++ b/packages/datagateway-dataview/src/views/card/dls/dlsDatasetsCardView.component.tsx @@ -15,12 +15,25 @@ import { useTextFilter, AddToCartButton, DLSDatasetDetailsPanel, + UploadButton, formatBytes, } from 'datagateway-common'; +import { styled } from '@mui/material/styles'; import { CalendarToday, Save } from '@mui/icons-material'; import ConfirmationNumberIcon from '@mui/icons-material/ConfirmationNumber'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { StateType } from 'datagateway-common'; + +const ActionButtonsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& button': { + margin: 'auto', + marginTop: theme.spacing(1), + }, +})); interface DLSDatasetsCVProps { proposalName: string; @@ -142,18 +155,27 @@ const DLSDatasetsCardView = (props: DLSDatasetsCVProps): React.ReactElement => { [dateFilter, t] ); + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + const buttons = React.useMemo( () => [ (dataset: Dataset) => ( - dataset.id) ?? []} - entityId={dataset.id} - parentId={investigationId} - /> + + dataset.id) ?? []} + entityId={dataset.id} + parentId={investigationId} + /> + {uploadUrl && ( + + )} + ), ], - [data, investigationId] + [data, investigationId, uploadUrl] ); return ( diff --git a/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.test.tsx b/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.test.tsx index 7b60bc467..a0971856f 100644 --- a/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.test.tsx +++ b/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.test.tsx @@ -65,6 +65,11 @@ describe('DLS Visits - Card View', () => { history = createMemoryHistory(); user = userEvent.setup(); + dGCommonInitialState.urls = { + ...dGCommonInitialState.urls, + uploadUrl: 'https://example.com/upload', + }; + mockStore = configureStore([thunk]); state = JSON.parse( JSON.stringify({ @@ -189,6 +194,13 @@ describe('DLS Visits - Card View', () => { ).toBeInTheDocument(); }); + it('renders buttons correctly', async () => { + renderComponent(); + expect( + await screen.findByRole('button', { name: 'buttons.upload_dataset' }) + ).toBeInTheDocument(); + }); + it('renders fine with incomplete data', () => { (useInvestigationCount as jest.Mock).mockReturnValueOnce({}); (useInvestigationsPaginated as jest.Mock).mockReturnValueOnce({}); diff --git a/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.tsx b/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.tsx index b11fc2d07..86acc9af3 100644 --- a/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.tsx +++ b/packages/datagateway-dataview/src/views/card/dls/dlsVisitsCardView.component.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { CardView, CardViewDetails, + Dataset, Investigation, tableLink, parseSearchToQuery, @@ -18,11 +19,14 @@ import { ArrowTooltip, DLSVisitDetailsPanel, formatBytes, + UploadButton, } from 'datagateway-common'; import { Assessment, CalendarToday, Save } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { Typography } from '@mui/material'; +import { useSelector } from 'react-redux'; +import { StateType } from 'datagateway-common'; interface DLSVisitsCVProps { proposalName: string; @@ -102,6 +106,20 @@ const DLSVisitsCardView = (props: DLSVisitsCVProps): React.ReactElement => { [t, textFilter] ); + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + + const buttons = React.useMemo( + () => [ + (dataset: Dataset) => + uploadUrl && ( + + ), + ], + [uploadUrl] + ); + const information: CardViewDetails[] = React.useMemo( () => [ { @@ -167,6 +185,7 @@ const DLSVisitsCardView = (props: DLSVisitsCVProps): React.ReactElement => { moreInformation={(investigation: Investigation) => ( )} + buttons={buttons} /> ); }; diff --git a/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.test.tsx b/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.test.tsx index e0007c557..e427b9263 100644 --- a/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.test.tsx +++ b/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.test.tsx @@ -91,6 +91,11 @@ describe('DLS Dataset table component', () => { history = createMemoryHistory(); user = userEvent.setup(); + dGCommonInitialState.urls = { + ...dGCommonInitialState.urls, + uploadUrl: 'https://example.com/upload', + }; + mockStore = configureStore([thunk]); state = JSON.parse( JSON.stringify({ @@ -363,4 +368,11 @@ describe('DLS Dataset table component', () => { expect(await screen.findByTestId('dls-dataset-details-panel')).toBeTruthy(); }); + + it('renders actions correctly', async () => { + renderComponent(); + expect( + await screen.findByRole('button', { name: 'buttons.upload_datafile' }) + ).toBeInTheDocument(); + }); }); diff --git a/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.tsx b/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.tsx index 445d059ee..7c7b5aa6d 100644 --- a/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.tsx +++ b/packages/datagateway-dataview/src/views/table/dls/dlsDatasetsTable.component.tsx @@ -22,6 +22,8 @@ import { useRemoveFromCart, DLSDatasetDetailsPanel, formatBytes, + UploadButton, + TableActionProps, } from 'datagateway-common'; import { IndexRange, TableCellProps } from 'react-virtualized'; import { useTranslation } from 'react-i18next'; @@ -29,6 +31,12 @@ import { useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { StateType } from '../../../state/app.types'; +const actions = [ + ({ rowData }: TableActionProps) => ( + + ), +]; + interface DLSDatasetsTableProps { proposalName: string; investigationId: string; @@ -186,6 +194,10 @@ const DLSDatasetsTable = (props: DLSDatasetsTableProps): React.ReactElement => { ); }, [cartItems, investigationId]); + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + return ( { disableSelectAll={!selectAllSetting} detailsPanel={DLSDatasetDetailsPanel} columns={columns} + actions={uploadUrl ? actions : undefined} /> ); }; diff --git a/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.test.tsx b/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.test.tsx index e4b49fdd1..86ed8e3c4 100644 --- a/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.test.tsx +++ b/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.test.tsx @@ -91,6 +91,11 @@ describe('DLS Visits table component', () => { history = createMemoryHistory(); user = userEvent.setup(); + dGCommonInitialState.urls = { + ...dGCommonInitialState.urls, + uploadUrl: 'https://example.com/upload', + }; + mockStore = configureStore([thunk]); state = JSON.parse( JSON.stringify({ @@ -344,4 +349,11 @@ describe('DLS Visits table component', () => { expect(instrumentNameCell).toHaveTextContent(''); }); + + it('renders actions correctly', async () => { + renderComponent(); + expect( + await screen.findByRole('button', { name: 'buttons.upload_dataset' }) + ).toBeInTheDocument(); + }); }); diff --git a/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.tsx b/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.tsx index 42b1cb4bc..d07c5778a 100644 --- a/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.tsx +++ b/packages/datagateway-dataview/src/views/table/dls/dlsVisitsTable.component.tsx @@ -11,6 +11,8 @@ import { useTextFilter, DLSVisitDetailsPanel, formatBytes, + UploadButton, + TableActionProps, } from 'datagateway-common'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -22,6 +24,18 @@ import { Save, } from '@mui/icons-material'; import { useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { StateType } from 'datagateway-common'; + +const actions = [ + ({ rowData }: TableActionProps) => ( + + ), +]; interface DLSVisitsTableProps { proposalName: string; @@ -148,6 +162,10 @@ const DLSVisitsTable = (props: DLSVisitsTableProps): React.ReactElement => { [t, dateFilter, textFilter, view, proposalName] ); + const uploadUrl = useSelector( + (state: StateType) => state.dgcommon.urls.uploadUrl + ); + return (
{ onSort={handleSort} detailsPanel={DLSVisitDetailsPanel} columns={columns} + actions={uploadUrl ? actions : undefined} /> ); }; diff --git a/packages/datagateway-download/package.json b/packages/datagateway-download/package.json index 58b082748..4147512d9 100644 --- a/packages/datagateway-download/package.json +++ b/packages/datagateway-download/package.json @@ -105,7 +105,7 @@ }, "jest": { "transformIgnorePatterns": [ - "node_modules/(?!axios)" + "node_modules/(?!(axios|@uppy|nanoid|exifr|p-queue|p-timeout)/)" ], "snapshotSerializers": [ "enzyme-to-json/serializer" diff --git a/packages/datagateway-download/public/logo192.png b/packages/datagateway-download/public/logo192.png deleted file mode 100644 index fa313abf5..000000000 Binary files a/packages/datagateway-download/public/logo192.png and /dev/null differ diff --git a/packages/datagateway-download/public/logo512.png b/packages/datagateway-download/public/logo512.png deleted file mode 100644 index bd5d4b5e2..000000000 Binary files a/packages/datagateway-download/public/logo512.png and /dev/null differ diff --git a/packages/datagateway-download/public/manifest.json b/packages/datagateway-download/public/manifest.json deleted file mode 100644 index 080d6c77a..000000000 --- a/packages/datagateway-download/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "React App", - "name": "Create React App Sample", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/packages/datagateway-download/public/robots.txt b/packages/datagateway-download/public/robots.txt deleted file mode 100644 index 01b0f9a10..000000000 --- a/packages/datagateway-download/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * diff --git a/packages/datagateway-download/src/index.css b/packages/datagateway-download/src/index.css index d07361807..325c05afd 100644 --- a/packages/datagateway-download/src/index.css +++ b/packages/datagateway-download/src/index.css @@ -1,19 +1,3 @@ body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -@keyframes rotate { - to { - transform: rotateZ(360deg); - } -} + margin: 0; /* can remove this when we're using CssBaseline (React 18 branch) */ +} \ No newline at end of file diff --git a/packages/datagateway-search/package.json b/packages/datagateway-search/package.json index 24f61d91b..66b07cac9 100644 --- a/packages/datagateway-search/package.json +++ b/packages/datagateway-search/package.json @@ -84,7 +84,7 @@ }, "jest": { "transformIgnorePatterns": [ - "node_modules/(?!axios)" + "node_modules/(?!(axios|@uppy|nanoid|exifr|p-queue|p-timeout)/)" ], "snapshotSerializers": [ "enzyme-to-json/serializer" diff --git a/packages/datagateway-search/src/App.css b/packages/datagateway-search/src/App.css deleted file mode 100644 index c6cb7523f..000000000 --- a/packages/datagateway-search/src/App.css +++ /dev/null @@ -1,29 +0,0 @@ -.App-logo { - animation: App-logo-spin infinite 20s linear; - height: 40vmin; - pointer-events: none; -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/packages/datagateway-search/src/App.tsx b/packages/datagateway-search/src/App.tsx index 89b44063b..7331dedc0 100644 --- a/packages/datagateway-search/src/App.tsx +++ b/packages/datagateway-search/src/App.tsx @@ -20,7 +20,6 @@ import { batch, connect, Provider } from 'react-redux'; import { AnyAction, applyMiddleware, compose, createStore, Store } from 'redux'; import { createLogger } from 'redux-logger'; import thunk, { ThunkDispatch } from 'redux-thunk'; -import './App.css'; import SearchPageContainer from './searchPageContainer.component'; import { configureApp } from './state/actions'; import { StateType } from './state/app.types'; diff --git a/packages/datagateway-search/src/index.css b/packages/datagateway-search/src/index.css index c0238d7cd..c12934096 100644 --- a/packages/datagateway-search/src/index.css +++ b/packages/datagateway-search/src/index.css @@ -1,26 +1,3 @@ -@media screen and (min-width: 960px) { - html { - margin-left: calc(100vw - 100%); - margin-right: 0; - } -} - body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -@keyframes rotate { - to { - transform: rotateZ(360deg); - } + margin: 0; /* can remove this when we're using CssBaseline (React 18 branch) */ } diff --git a/yarn.lock b/yarn.lock index 38eded315..f54913bc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3092,6 +3092,20 @@ __metadata: languageName: node linkType: hard +"@transloadit/prettier-bytes@npm:0.0.7": + version: 0.0.7 + resolution: "@transloadit/prettier-bytes@npm:0.0.7" + checksum: af075e1b2b045cac55d5ab854b5c94273ef7f4bd825f05fe578559465f206d2a1535343b50170252a9efb31ffdc2d6420e64eb32d2faeabeeb8b7d81d5d46ef0 + languageName: node + linkType: hard + +"@transloadit/prettier-bytes@npm:0.0.9": + version: 0.0.9 + resolution: "@transloadit/prettier-bytes@npm:0.0.9" + checksum: efa5a723c41e7bce7ad17d1affe6a43209df857e17dc2b12a7c7bd6d3c921df8298086dbfb62ed740ca3e617d8c7f47485bb311adb637b20f2f75a28b08bac4f + languageName: node + linkType: hard + "@trysound/sax@npm:0.2.0": version: 0.2.0 resolution: "@trysound/sax@npm:0.2.0" @@ -3610,6 +3624,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.2": + version: 0.12.2 + resolution: "@types/retry@npm:0.12.2" + checksum: e5675035717b39ce4f42f339657cae9637cf0c0051cf54314a6a2c44d38d91f6544be9ddc0280587789b6afd056be5d99dbe3e9f4df68c286c36321579b1bf4a + languageName: node + linkType: hard + "@types/scheduler@npm:*": version: 0.16.2 resolution: "@types/scheduler@npm:0.16.2" @@ -3932,6 +3953,228 @@ __metadata: languageName: node linkType: hard +"@uppy/companion-client@npm:^3.6.0": + version: 3.6.0 + resolution: "@uppy/companion-client@npm:3.6.0" + dependencies: + "@uppy/utils": ^5.6.0 + namespace-emitter: ^2.0.1 + p-retry: ^6.1.0 + checksum: d22e0c13adc94efbec065943833b6a43926805bed91423a17b4cda3699334e16a1c00197fa59f26f935355bb1b561a8e6faada51baa387d68f21ed3553746989 + languageName: node + linkType: hard + +"@uppy/core@npm:^3.7.1": + version: 3.7.1 + resolution: "@uppy/core@npm:3.7.1" + dependencies: + "@transloadit/prettier-bytes": 0.0.9 + "@uppy/store-default": ^3.0.5 + "@uppy/utils": ^5.6.0 + lodash: ^4.17.21 + mime-match: ^1.0.2 + namespace-emitter: ^2.0.1 + nanoid: ^4.0.0 + preact: ^10.5.13 + checksum: 6561cfa0218cf665371ec9caef3486ff216ca2daaafdc7b99567d4e8c1ec360707d275e2ac46769ce1244529cf5f871fe05c35b35f1604af6ec4bb8dfbea1f26 + languageName: node + linkType: hard + +"@uppy/dashboard@npm:^3.7.1": + version: 3.7.1 + resolution: "@uppy/dashboard@npm:3.7.1" + dependencies: + "@transloadit/prettier-bytes": 0.0.7 + "@uppy/informer": ^3.0.4 + "@uppy/provider-views": ^3.7.0 + "@uppy/status-bar": ^3.2.5 + "@uppy/thumbnail-generator": ^3.0.6 + "@uppy/utils": ^5.6.0 + classnames: ^2.2.6 + is-shallow-equal: ^1.0.1 + lodash: ^4.17.21 + memoize-one: ^6.0.0 + nanoid: ^4.0.0 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.7.1 + checksum: 0e86f6e0cf15034479d22767349f5d3bc92961f73d0f1d68891f68d1e3325b1b27adab838605440c72e9bed87b73cb8a2d9a6781d8eb5177b468987011324e61 + languageName: node + linkType: hard + +"@uppy/drag-drop@npm:^3.0.3": + version: 3.0.3 + resolution: "@uppy/drag-drop@npm:3.0.3" + dependencies: + "@uppy/utils": ^5.4.3 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.4.0 + checksum: e3eacbc6df61e33c22c934a2fcc51865e4cd8e8d6712690c79d24311ee6afab08a4cc40e383cfb404c0e76a93d1810c309a652592faff0c4b9ed4cb673e41e99 + languageName: node + linkType: hard + +"@uppy/file-input@npm:^3.0.4": + version: 3.0.4 + resolution: "@uppy/file-input@npm:3.0.4" + dependencies: + "@uppy/utils": ^5.5.2 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: 4e3e7e44d5651613fe531a2874f578e89060ecafa9a9eb77b8bb7caf16bea921982b55bf1f6930216d29749a5d9229eb48761e0812a6376c68f8cabda13ab915 + languageName: node + linkType: hard + +"@uppy/form@npm:^3.0.3": + version: 3.0.3 + resolution: "@uppy/form@npm:3.0.3" + dependencies: + "@uppy/utils": ^5.5.2 + get-form-data: ^3.0.0 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: 705a323713688312f3efa846cf47d458482b8d301e88fedbe5df873991df795f14ce8044e31b7b5495793d8e3a4530fabb71bf7f1af6cee9dedd9917d5e5ec5c + languageName: node + linkType: hard + +"@uppy/golden-retriever@npm:^3.1.1": + version: 3.1.1 + resolution: "@uppy/golden-retriever@npm:3.1.1" + dependencies: + "@transloadit/prettier-bytes": 0.0.9 + "@uppy/utils": ^5.5.2 + lodash: ^4.17.21 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: 6126ff6b1bc87e7f47c89eb68e0d1e458f73ae36d65707529602df10e72d1a4fd893cf969598924e992e32a3e7b8125c8910909cff3142d0db03ea5687617098 + languageName: node + linkType: hard + +"@uppy/informer@npm:^3.0.4": + version: 3.0.4 + resolution: "@uppy/informer@npm:3.0.4" + dependencies: + "@uppy/utils": ^5.5.2 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: e4bffe78fe08601a36e59ef98439e84aa10833b8f286af2e3d85cd60cce6161e691312c8e1ac2f3c5552252e76bc62800e969bbc2f17bb40a728ff473f2495fd + languageName: node + linkType: hard + +"@uppy/progress-bar@npm:^3.0.4": + version: 3.0.4 + resolution: "@uppy/progress-bar@npm:3.0.4" + dependencies: + "@uppy/utils": ^5.5.2 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: 18cd69471946c11a1575ba760dc7c92cb17c4a72599b2377833ec0d76876e54dc2fdbf38d1fd35404f4b2031e4024c30fe0ad8ff7b91ce4028e9f8cf3334357d + languageName: node + linkType: hard + +"@uppy/provider-views@npm:^3.7.0": + version: 3.7.0 + resolution: "@uppy/provider-views@npm:3.7.0" + dependencies: + "@uppy/utils": ^5.6.0 + classnames: ^2.2.6 + nanoid: ^4.0.0 + p-queue: ^7.3.4 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.7.0 + checksum: 8f1d499a4f5ca4dd658115ba34358cecba99bc1d67cd7fee01761eef5a74a347739f79c44a35638fe1074175a70640af8a5cfd327e02bcf6f7a446d91a102f52 + languageName: node + linkType: hard + +"@uppy/react@npm:^3.2.1": + version: 3.2.1 + resolution: "@uppy/react@npm:3.2.1" + dependencies: + "@uppy/utils": ^5.6.0 + prop-types: ^15.6.1 + peerDependencies: + "@uppy/core": ^3.7.1 + "@uppy/dashboard": ^3.7.1 + "@uppy/drag-drop": ^3.0.3 + "@uppy/file-input": ^3.0.4 + "@uppy/progress-bar": ^3.0.4 + "@uppy/status-bar": ^3.2.5 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@uppy/dashboard": + optional: true + "@uppy/drag-drop": + optional: true + "@uppy/file-input": + optional: true + "@uppy/progress-bar": + optional: true + "@uppy/status-bar": + optional: true + checksum: 273795bca3619e2e1870c0464cce07c38403a2eded3fdafd962513f4510e3e01bc622b216f0138d59f0070044e27565297a022575e4bad2d8308ec6eac1635e6 + languageName: node + linkType: hard + +"@uppy/status-bar@npm:^3.2.5": + version: 3.2.5 + resolution: "@uppy/status-bar@npm:3.2.5" + dependencies: + "@transloadit/prettier-bytes": 0.0.9 + "@uppy/utils": ^5.5.2 + classnames: ^2.2.6 + preact: ^10.5.13 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: 55a494b03885517bc0be8f27c455b122cdda4f823136e3fae7f692494684bf2a931024728f0b57530ece788a22e6b1ebeaedf4e9b58acaa6de439b97c39f76c0 + languageName: node + linkType: hard + +"@uppy/store-default@npm:^3.0.5": + version: 3.0.5 + resolution: "@uppy/store-default@npm:3.0.5" + checksum: b609574033fd4564938f1db6bbe8eba3be979c55d37fd2f359bf0659e1c813d7254158616199041c6d1cf2ad97f4762c924bd73c28815192dc3ac0dcb5289773 + languageName: node + linkType: hard + +"@uppy/thumbnail-generator@npm:^3.0.6": + version: 3.0.6 + resolution: "@uppy/thumbnail-generator@npm:3.0.6" + dependencies: + "@uppy/utils": ^5.5.2 + exifr: ^7.0.0 + peerDependencies: + "@uppy/core": ^3.6.0 + checksum: d78ebc96f99f54d0ec3621f5e2ff8e71a624fbb184633cfd2a350bd9b9bc76eca630d3b5fa28c30424e06f9027d8d85789a88e99ff03244523808d82109cb299 + languageName: node + linkType: hard + +"@uppy/tus@npm:^3.4.0": + version: 3.4.0 + resolution: "@uppy/tus@npm:3.4.0" + dependencies: + "@uppy/companion-client": ^3.6.0 + "@uppy/utils": ^5.6.0 + tus-js-client: ^3.0.0 + peerDependencies: + "@uppy/core": ^3.7.0 + checksum: 0f6393be525937cba1b624fffc0f0c9cb00f7162efb6fe5a98f2b6a446f0f0b22323950a5ceba4c16c473c9a99bbc161511e04f3e134c06cdea4337428ef2962 + languageName: node + linkType: hard + +"@uppy/utils@npm:^5.4.3, @uppy/utils@npm:^5.5.2, @uppy/utils@npm:^5.6.0": + version: 5.6.0 + resolution: "@uppy/utils@npm:5.6.0" + dependencies: + lodash: ^4.17.21 + preact: ^10.5.13 + checksum: 51456f691b50efed4aacd6600aad8a2111e57ec2dc8f0f02982a13c863243eeda016edce8b0fabbfbe277b2286e9b56786d74995c76a97a6194a5461ffee77e0 + languageName: node + linkType: hard + "@webassemblyjs/ast@npm:1.11.1": version: 1.11.1 resolution: "@webassemblyjs/ast@npm:1.11.1" @@ -5167,7 +5410,7 @@ __metadata: languageName: node linkType: hard -"buffer-from@npm:^1.0.0": +"buffer-from@npm:^1.0.0, buffer-from@npm:^1.1.2": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb @@ -5484,7 +5727,7 @@ __metadata: languageName: node linkType: hard -"classnames@npm:^2.2.5": +"classnames@npm:^2.2.5, classnames@npm:^2.2.6": version: 2.3.2 resolution: "classnames@npm:2.3.2" checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e @@ -5692,6 +5935,16 @@ __metadata: languageName: node linkType: hard +"combine-errors@npm:^3.0.3": + version: 3.0.3 + resolution: "combine-errors@npm:3.0.3" + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + checksum: bd0b0d2a4020f9976b8fe8eb7d5aa855b43ecacdcb61ee1fc5664d73ff8c1d7d0bbe4dd948bea7ba1870518bfc5688b89941de7a4967659418b4664cdb02884f + languageName: node + linkType: hard + "combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -6290,6 +6543,13 @@ __metadata: languageName: node linkType: hard +"custom-error-instance@npm:2.1.1": + version: 2.1.1 + resolution: "custom-error-instance@npm:2.1.1" + checksum: db01483864c9f4356b720b443a1f9b374758745a75199187a0ccc12505cf822bc801a0d8e3f96d727559880024f40e09667d5c08e5de0bff243c6b5ae0bd303c + languageName: node + linkType: hard + "custom-event-polyfill@npm:1.0.7": version: 1.0.7 resolution: "custom-event-polyfill@npm:1.0.7" @@ -6409,6 +6669,15 @@ __metadata: "@types/react-virtualized": 9.21.10 "@typescript-eslint/eslint-plugin": 5.62.0 "@typescript-eslint/parser": 5.62.0 + "@uppy/core": ^3.7.1 + "@uppy/dashboard": ^3.7.1 + "@uppy/drag-drop": ^3.0.3 + "@uppy/file-input": ^3.0.4 + "@uppy/form": ^3.0.3 + "@uppy/golden-retriever": ^3.1.1 + "@uppy/progress-bar": ^3.0.4 + "@uppy/react": ^3.2.1 + "@uppy/tus": ^3.4.0 axios: 1.6.1 connected-react-router: 6.9.1 date-fns: 2.30.0 @@ -8059,6 +8328,13 @@ __metadata: languageName: node linkType: hard +"exifr@npm:^7.0.0": + version: 7.1.3 + resolution: "exifr@npm:7.1.3" + checksum: c75a21e700378a29d226dd2c102d43679a68539ff1774b05ca19acce366ad47d056985e4f497afe7da6d0d8966d7dc73efb51acc11ff84fa57c9467e630c67ff + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -8666,6 +8942,13 @@ __metadata: languageName: node linkType: hard +"get-form-data@npm:^3.0.0": + version: 3.0.0 + resolution: "get-form-data@npm:3.0.0" + checksum: 3e3f80531cdfa434b04f40e1a25dcf034a56dab8c5dae0f77b5e652b1463448ee43b460acfdd6f9dfe3c6d890628cc770d4070524ed58df11094df012066e23a + languageName: node + linkType: hard + "get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3": version: 1.2.0 resolution: "get-intrinsic@npm:1.2.0" @@ -9670,6 +9953,13 @@ __metadata: languageName: node linkType: hard +"is-network-error@npm:^1.0.0": + version: 1.0.0 + resolution: "is-network-error@npm:1.0.0" + checksum: 2ca2b4b2d420015e0237abe28ebf316fcd26a82304b07432abf155759a3bee6895609ac91e692a72ad61b7fc902c3283b2dece61e1ddb05a6257777a8573e468 + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.7 resolution: "is-number-object@npm:1.0.7" @@ -9761,6 +10051,13 @@ __metadata: languageName: node linkType: hard +"is-shallow-equal@npm:^1.0.1": + version: 1.0.1 + resolution: "is-shallow-equal@npm:1.0.1" + checksum: 6bd0981c14c4c9219449c9bea4c73035defc4544394b96392573515046c1165c770a19a87c88457c40f0983f94f6750d14b0d207b27be00727e273dc4e0f5a35 + languageName: node + linkType: hard + "is-shared-array-buffer@npm:^1.0.2": version: 1.0.2 resolution: "is-shared-array-buffer@npm:1.0.2" @@ -10645,6 +10942,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^3.7.2": + version: 3.7.5 + resolution: "js-base64@npm:3.7.5" + checksum: 67a78c8b1c47b73f1c6fba1957e9fe6fd9dc78ac93ac46cc2e43472dcb9cf150d126fb0e593192e88e0497354fa634d17d255add7cc6ee3c7b4d29870faa8e18 + languageName: node + linkType: hard + "js-sha3@npm:0.8.0": version: 0.8.0 resolution: "js-sha3@npm:0.8.0" @@ -11056,6 +11360,55 @@ __metadata: languageName: node linkType: hard +"lodash._baseiteratee@npm:~4.7.0": + version: 4.7.0 + resolution: "lodash._baseiteratee@npm:4.7.0" + dependencies: + lodash._stringtopath: ~4.8.0 + checksum: 814a7125b9e2fa7e436c4402eae842a200189e2839b56bd6cde7cd0a3628b60842f5d39a9f5dceaf8766669b2e4a17a36ce2a213d1d6a891c1bef8a6bda36ea9 + languageName: node + linkType: hard + +"lodash._basetostring@npm:~4.12.0": + version: 4.12.0 + resolution: "lodash._basetostring@npm:4.12.0" + checksum: ccaf83827f86be5c9daeb7b939f761d6a43f0de0781bc3b6772fcb8568fbcbfa1e1082c66e5e12dd23e00ac40a18349c5a793a6a552e3574cbbcb3e1545fcb4c + languageName: node + linkType: hard + +"lodash._baseuniq@npm:~4.6.0": + version: 4.6.0 + resolution: "lodash._baseuniq@npm:4.6.0" + dependencies: + lodash._createset: ~4.0.0 + lodash._root: ~3.0.0 + checksum: 8c16fe2e80716b18c2f28bbcc902768141d432b0b98e03b30a2fba6a097377fabdc8753da232568375d2aa9502dc6b3a390200aa1467d2f685a582a46a271936 + languageName: node + linkType: hard + +"lodash._createset@npm:~4.0.0": + version: 4.0.3 + resolution: "lodash._createset@npm:4.0.3" + checksum: fb4450fbf4846aa7b420837ee44400b88664e28499388b7e04b4db38adca1305915f68a245fb2a87e031e7f440b997de4f360de6dea2712952520e97c7898de1 + languageName: node + linkType: hard + +"lodash._root@npm:~3.0.0": + version: 3.0.1 + resolution: "lodash._root@npm:3.0.1" + checksum: 3e12c6f409ae13164a8db358f44a691f1e038dad4e25463802980d0ed641ed118c147b65657501c51778c885422b913264dfbe33ec0c5d676443dd630a7e685a + languageName: node + linkType: hard + +"lodash._stringtopath@npm:~4.8.0": + version: 4.8.0 + resolution: "lodash._stringtopath@npm:4.8.0" + dependencies: + lodash._basetostring: ~4.12.0 + checksum: 00663b317796333e6315ebb4e8b590e68845de10d5d25c7585751fd9d28adf3e60e1ce85a6fbb6a0d440447c841465b91877e761239e358231eed2f52f0a5472 + languageName: node + linkType: hard + "lodash.chunk@npm:4.2.0": version: 4.2.0 resolution: "lodash.chunk@npm:4.2.0" @@ -11133,6 +11486,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 129c0a28cee48b348aef146f638ef8a8b197944d4e9ec26c1890c19d9bf5a5690fe11b655c77a4551268819b32d27f4206343e30c78961f60b561b8608c8c805 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -11140,6 +11500,16 @@ __metadata: languageName: node linkType: hard +"lodash.uniqby@npm:4.5.0": + version: 4.5.0 + resolution: "lodash.uniqby@npm:4.5.0" + dependencies: + lodash._baseiteratee: ~4.7.0 + lodash._baseuniq: ~4.6.0 + checksum: 40a4fdd4c31323fcb6db91ec3124020333212ca1f13e75cc9939decdd33e8b176d204fb277be36a51a855c2c90e14d67932b3b130b2f0eedc729e4cb9cdcaed1 + languageName: node + linkType: hard + "lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.7.0": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -11357,6 +11727,13 @@ __metadata: languageName: node linkType: hard +"memoize-one@npm:^6.0.0": + version: 6.0.0 + resolution: "memoize-one@npm:6.0.0" + checksum: f185ea69f7cceae5d1cb596266dcffccf545e8e7b4106ec6aa93b71ab9d16460dd118ac8b12982c55f6d6322fcc1485de139df07eacffaae94888b9b3ad7675f + languageName: node + linkType: hard + "merge-descriptors@npm:1.0.1": version: 1.0.1 resolution: "merge-descriptors@npm:1.0.1" @@ -11416,6 +11793,15 @@ __metadata: languageName: node linkType: hard +"mime-match@npm:^1.0.2": + version: 1.0.2 + resolution: "mime-match@npm:1.0.2" + dependencies: + wildcard: ^1.1.0 + checksum: 3e4afd6be98e20bfb421146a14147560941f471886e6d3534372b37d29bb7e35a7462e1f9cee98312f92e44969ae9deca2da7ad91ab5a738af55a7d5f03a6814 + languageName: node + linkType: hard + "mime-types@npm:2.1.18": version: 2.1.18 resolution: "mime-types@npm:2.1.18" @@ -11659,6 +12045,13 @@ __metadata: languageName: node linkType: hard +"namespace-emitter@npm:^2.0.1": + version: 2.0.1 + resolution: "namespace-emitter@npm:2.0.1" + checksum: 49ae49674d5b83e18ad91c549ed264df1fcba02d039617e0932dab618f79e3749de6d878d54962b5cbf2611c0eafc91203a376980399c33c30edf542f19cb034 + languageName: node + linkType: hard + "nano-time@npm:1.0.0": version: 1.0.0 resolution: "nano-time@npm:1.0.0" @@ -11677,6 +12070,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^4.0.0": + version: 4.0.2 + resolution: "nanoid@npm:4.0.2" + bin: + nanoid: bin/nanoid.js + checksum: 747c399cea4664dd0be1d0ec498ffd1ef8f1f5221676fc8b577e3f46f66d9afcddb9595d63d19a2e78d0bc6cc33984f65e66bf1682c850b9e26288883d96b53f + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -12136,6 +12538,16 @@ __metadata: languageName: node linkType: hard +"p-queue@npm:^7.3.4": + version: 7.4.1 + resolution: "p-queue@npm:7.4.1" + dependencies: + eventemitter3: ^5.0.1 + p-timeout: ^5.0.2 + checksum: 1c6888aa994d399262a9fbdd49c7066f8359732397f7a42ecf03f22875a1d65899797b46413f97e44acc18dddafbcc101eb135c284714c931dbbc83c3967f450 + languageName: node + linkType: hard + "p-retry@npm:^4.5.0": version: 4.6.2 resolution: "p-retry@npm:4.6.2" @@ -12146,6 +12558,24 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^6.1.0": + version: 6.1.0 + resolution: "p-retry@npm:6.1.0" + dependencies: + "@types/retry": 0.12.2 + is-network-error: ^1.0.0 + retry: ^0.13.1 + checksum: 1083b2b72672205680f8a736583e31dce5d4ae472996cd06f4a33cd7ea11798d7712c202d253eb8afbdc80abf52f049651989c59f2e2ccca529e6b64d722b1f7 + languageName: node + linkType: hard + +"p-timeout@npm:^5.0.2": + version: 5.1.0 + resolution: "p-timeout@npm:5.1.0" + checksum: f5cd4e17301ff1ff1d8dbf2817df0ad88c6bba99349fc24d8d181827176ad4f8aca649190b8a5b1a428dfd6ddc091af4606835d3e0cb0656e04045da5c9e270c + languageName: node + linkType: hard + "p-try@npm:^2.0.0": version: 2.2.0 resolution: "p-try@npm:2.2.0" @@ -13232,6 +13662,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.5.13": + version: 10.19.2 + resolution: "preact@npm:10.19.2" + checksum: fec27fa3f14ac2d7a5061818d0cf2973ffaece83126047a47e5a075aa8e40ca56b5fcebc36106ee9cf59be0aeb51f3d996760e158d2a2660b42cbfb2e71f37bf + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -13365,7 +13802,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.0, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.6.0, prop-types@npm:^15.6.1, prop-types@npm:^15.6.2, prop-types@npm:^15.7.0, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -13376,6 +13813,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: ^4.2.4 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939 + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -15817,6 +16265,21 @@ __metadata: languageName: node linkType: hard +"tus-js-client@npm:^3.0.0": + version: 3.1.1 + resolution: "tus-js-client@npm:3.1.1" + dependencies: + buffer-from: ^1.1.2 + combine-errors: ^3.0.3 + is-stream: ^2.0.0 + js-base64: ^3.7.2 + lodash.throttle: ^4.1.1 + proper-lockfile: ^4.1.2 + url-parse: ^1.5.7 + checksum: 7cb227b8d94e1f48a172f52c0cf8f0d58a7866211157788c6a8c9d7512550d21ef5ed13d074b8ffadbecc7e730273eca9f346d0ea4c89d9da60f351162a68d02 + languageName: node + linkType: hard + "tweetnacl@npm:^0.14.3, tweetnacl@npm:~0.14.0": version: 0.14.5 resolution: "tweetnacl@npm:0.14.5" @@ -16096,7 +16559,7 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.3": +"url-parse@npm:^1.5.3, url-parse@npm:^1.5.7": version: 1.5.10 resolution: "url-parse@npm:1.5.10" dependencies: @@ -16600,6 +17063,13 @@ __metadata: languageName: node linkType: hard +"wildcard@npm:^1.1.0": + version: 1.1.2 + resolution: "wildcard@npm:1.1.2" + checksum: f93bf48a23b7b776f7960fa7f252af55da265b4ce8127852e420f04a907b78073bc0412f74fc662f561667f3277473974f6553a260ece67f53b1975d128320ab + languageName: node + linkType: hard + "wildcard@npm:^2.0.0": version: 2.0.0 resolution: "wildcard@npm:2.0.0"