Skip to content
This repository was archived by the owner on Feb 8, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ea2bfd7
Refactor `FileTransfer`
gzdunek Sep 30, 2022
672f18b
Do not show hover on disabled `ButtonIcon`
gzdunek Sep 30, 2022
af3d61a
Use new `FileTransfer` in Web UI
gzdunek Sep 30, 2022
4c60cae
Use new `FileTransfer` in Connect
gzdunek Sep 30, 2022
09c9298
Update protobuf files
gzdunek Sep 30, 2022
ee465da
Remove old `FileTransfer`
gzdunek Sep 30, 2022
c3cde59
Replace `extractFilesFromFileList` with `Array.from`
gzdunek Oct 3, 2022
c5b348b
Fix `files` deps
gzdunek Oct 3, 2022
aa8afe8
Merge branch 'master' into gzdunek/refactor-file-transfer
gzdunek Oct 3, 2022
6b491cd
Use `div` for `Dropzone` element
gzdunek Oct 4, 2022
daceb3d
Simplify condition
gzdunek Oct 4, 2022
160881e
Remove unnecessary deps, use `unique` for id
gzdunek Oct 4, 2022
e3b4d7c
Change test names
gzdunek Oct 4, 2022
f4cb53b
Use `waitForElementToBeRemoved`
gzdunek Oct 4, 2022
d1a9ad4
Add comment to `TransferHandlers`, update tests names
gzdunek Oct 5, 2022
9d6a06c
Revert "use `unique` for id"
gzdunek Oct 5, 2022
3a358bd
Change `ButtonIcon` disabled state styles
gzdunek Oct 5, 2022
40b5beb
Add `retryWithRelogin` download/upload
gzdunek Oct 5, 2022
89e6e1f
Merge branch 'master' into gzdunek/refactor-file-transfer
gzdunek Oct 5, 2022
ee91444
Send `hostname` instead of `serverUri`
gzdunek Oct 5, 2022
4c0fca9
Update protobuf files
gzdunek Oct 5, 2022
c136931
Return `FileTransferListeners` from handlers instead of passing them …
gzdunek Oct 6, 2022
66e58aa
Get rid of `FileTransferRequest.AsObject`
gzdunek Oct 6, 2022
c7449fd
Rename `FileTransferClient` to `FileTransferService`
gzdunek Oct 6, 2022
106d5cc
Merge branch 'master' into gzdunek/refactor-file-transfer
gzdunek Oct 10, 2022
a9cba6f
Remove `inputCss`
gzdunek Oct 10, 2022
884c4a6
Update snapshots with fixed `ButtonIcon` hover and focus styles
gzdunek Oct 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/design/src/ButtonIcon/ButtonIcon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ const fromProps = props => {
return {
'&:disabled': {
color: theme.colors.action.disabled,
cursor: 'default',
},
'&:hover, &:focus': {
'&:hover:enabled, &:focus:enabled': {
background: theme.colors.action.hover,
},
};
Expand Down
1 change: 1 addition & 0 deletions packages/design/src/theme/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const contrastThreshold = 3;

const colors = {
accent: '#651FFF',
progressBarColor: '#00BFA5',

dark: '#000',

Expand Down
2 changes: 2 additions & 0 deletions packages/design/src/utils/testing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
screen,
prettyDOM,
getByTestId,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter as Router } from 'react-router-dom';
Expand Down Expand Up @@ -60,4 +61,5 @@ export {
getByTestId,
Router,
userEvent,
waitForElementToBeRemoved,
};
170 changes: 170 additions & 0 deletions packages/shared/components/FileTransfer/FileTransfer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Copyright 2022 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';
import {
act,
fireEvent,
render,
screen,
waitForElementToBeRemoved,
} from 'design/utils/testing';

import { FileTransfer, TransferHandlers } from './FileTransfer';
import { FileTransferContextProvider } from './FileTransferContextProvider';
import { FileTransferDialogDirection } from './FileTransferStateless';
import { createFileTransferEventsEmitter } from './createFileTransferEventsEmitter';

test('click opens correct dialog', () => {
render(
<FileTransferContextProvider
openedDialog={FileTransferDialogDirection.Download}
>
<FileTransfer beforeClose={undefined} transferHandlers={undefined} />
</FileTransferContextProvider>
);
expect(screen.getByText('Download Files')).toBeInTheDocument();
});

test('downloads component changes when file transfer callbacks are called', async () => {
const fileTransferEvents = createFileTransferEventsEmitter();

const handler: TransferHandlers = {
getDownloader: async () => fileTransferEvents,
getUploader: async () => undefined,
};
render(
<FileTransferContextProvider
openedDialog={FileTransferDialogDirection.Download}
>
<FileTransfer beforeClose={undefined} transferHandlers={handler} />
</FileTransferContextProvider>
);
fireEvent.change(screen.getByLabelText('File Path'), {
target: { value: '/Users/g/file.txt' },
});
fireEvent.click(screen.getByText('Download'));
const listItem = await screen.findByRole('listitem');
expect(listItem).toHaveTextContent('/Users/g/file.txt');

act(() => fileTransferEvents.emitProgress(50));
expect(listItem).toHaveTextContent('50%');

act(() => fileTransferEvents.emitComplete());
expect(listItem).toContainElement(screen.getByTitle('Transfer completed'));

act(() => fileTransferEvents.emitError(new Error('Network error')));
expect(listItem).toHaveTextContent('Network error');
});

test('onAbort is called when user cancels upload', async () => {
let abortControllerMock: AbortController;

const handler: TransferHandlers = {
getDownloader: async (_, abortController) => {
abortControllerMock = abortController;
return createFileTransferEventsEmitter();
},
getUploader: async () => undefined,
};
render(
<FileTransferContextProvider
openedDialog={FileTransferDialogDirection.Download}
>
<FileTransfer beforeClose={undefined} transferHandlers={handler} />
</FileTransferContextProvider>
);
fireEvent.change(screen.getByLabelText('File Path'), {
target: { value: '/Users/g/file.txt' },
});
fireEvent.click(screen.getByText('Download'));
fireEvent.click(await screen.findByTitle('Cancel'));
expect(abortControllerMock.signal.aborted).toBeTruthy();
});

test('file is not added when transferHandler does not return anything', async () => {
const handler: TransferHandlers = {
getDownloader: async () => undefined,
getUploader: async () => undefined,
};
const filePath = '/Users/g/file.txt';

render(
<FileTransferContextProvider
openedDialog={FileTransferDialogDirection.Download}
>
<FileTransfer beforeClose={undefined} transferHandlers={handler} />
</FileTransferContextProvider>
);
fireEvent.change(screen.getByLabelText('File Path'), {
target: { value: filePath },
});
fireEvent.click(screen.getByText('Download'));
expect(screen.queryByText('/Users/g/file.txt')).not.toBeInTheDocument();
});

describe('handleAfterClose', () => {
const getSetup = async () => {
const handleBeforeClose = jest.fn();
const handleAfterClose = jest.fn();
const handler: TransferHandlers = {
getDownloader: async () => createFileTransferEventsEmitter(),
getUploader: async () => undefined,
};

render(
<FileTransferContextProvider
openedDialog={FileTransferDialogDirection.Download}
>
<FileTransfer
beforeClose={handleBeforeClose}
afterClose={handleAfterClose}
transferHandlers={handler}
/>
</FileTransferContextProvider>
);

fireEvent.change(screen.getByLabelText('File Path'), {
target: { value: '~/abc' },
});

fireEvent.click(screen.getByText('Download'));
await screen.findByRole('listitem');

return { handleBeforeClose, handleAfterClose };
};

test('is not called when closing the dialog has been aborted', async () => {
const { handleBeforeClose, handleAfterClose } = await getSetup();
handleBeforeClose.mockReturnValue(Promise.resolve(false));
fireEvent.click(screen.getByTitle('Close'));
expect(handleBeforeClose).toHaveBeenCalled();
expect(handleAfterClose).not.toHaveBeenCalled();
});

test('is called when closing the dialog has been confirmed', async () => {
const { handleBeforeClose, handleAfterClose } = await getSetup();
handleBeforeClose.mockReturnValue(Promise.resolve(true));
fireEvent.click(screen.getByTitle('Close'));
expect(handleBeforeClose).toHaveBeenCalled();

// wait for dialog to close
await waitForElementToBeRemoved(() =>
screen.queryByTestId('file-transfer-container')
);
expect(handleAfterClose).toHaveBeenCalled();
});
});
135 changes: 135 additions & 0 deletions packages/shared/components/FileTransfer/FileTransfer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Copyright 2022 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';

import { useFileTransferContext } from './FileTransferContextProvider';
import { useFilesStore } from './useFilesStore';
import {
FileTransferDialogDirection,
FileTransferListeners,
FileTransferStateless,
} from './FileTransferStateless';

interface FileTransferProps {
backgroundColor?: string;
transferHandlers: TransferHandlers;

/**
* `beforeClose` is called when an attempt to close the dialog was made
* and there is a file transfer in progress.
* Returning `true` will close the dialog, returning `false` will not.
*/
beforeClose?(): Promise<boolean> | boolean;

afterClose?(): void;
}

/**
* Both `getDownloader` and `getUploader` can return a promise containing `FileTransferListeners` function or nothing.
* In the latter case, the file will not be added to the list and the download will not start.
*/
export interface TransferHandlers {
getDownloader: (
sourcePath: string,
abortController: AbortController
) => Promise<FileTransferListeners | undefined>;
getUploader: (
destinationPath: string,
file: File,
abortController: AbortController
) => Promise<FileTransferListeners | undefined>;
}

export function FileTransfer(props: FileTransferProps) {
const { openedDialog, closeDialog } = useFileTransferContext();

async function handleCloseDialog(
isAnyTransferInProgress: boolean
): Promise<void> {
const runCloseCallbacks = () => {
closeDialog();
props.afterClose?.();
};

if (!isAnyTransferInProgress || !props.beforeClose) {
runCloseCallbacks();
return;
}

if (await props.beforeClose()) {
runCloseCallbacks();
}
}

if (!openedDialog) {
return null;
}

return (
<FileTransferDialog
openedDialog={openedDialog}
backgroundColor={props.backgroundColor}
transferHandlers={props.transferHandlers}
onCloseDialog={handleCloseDialog}
/>
);
}

export function FileTransferDialog(
props: Pick<FileTransferProps, 'transferHandlers' | 'backgroundColor'> & {
openedDialog: FileTransferDialogDirection;
onCloseDialog(isAnyTransferInProgress: boolean): void;
}
) {
const filesStore = useFilesStore();

function handleAddDownload(sourcePath: string): void {
filesStore.start({
name: sourcePath,
runFileTransfer: abortController =>
props.transferHandlers.getDownloader(sourcePath, abortController),
});
}

function handleAddUpload(destinationPath: string, file: File): void {
filesStore.start({
name: file.name,
runFileTransfer: abortController =>
props.transferHandlers.getUploader(
destinationPath,
file,
abortController
),
});
}

function handleClose(): void {
props.onCloseDialog(filesStore.isAnyTransferInProgress());
}

return (
<FileTransferStateless
openedDialog={props.openedDialog}
files={filesStore.files}
onCancel={filesStore.cancel}
backgroundColor={props.backgroundColor}
onClose={handleClose}
onAddUpload={handleAddUpload}
onAddDownload={handleAddDownload}
/>
);
}
54 changes: 54 additions & 0 deletions packages/shared/components/FileTransfer/FileTransferActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Copyright 2022 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';
import { Flex, ButtonIcon } from 'design';
import * as Icons from 'design/Icon';

import { useFileTransferContext } from './FileTransferContextProvider';

type FileTransferActionBarProps = {
isConnected: boolean;
};

export function FileTransferActionBar({
isConnected,
}: FileTransferActionBarProps) {
const fileTransferContext = useFileTransferContext();
const areFileTransferButtonsDisabled =
fileTransferContext.openedDialog || !isConnected;

return (
<Flex flex="none" alignItems="center" height="24px">
<ButtonIcon
disabled={areFileTransferButtonsDisabled}
size={0}
title="Download files"
onClick={fileTransferContext.openDownloadDialog}
>
<Icons.Download fontSize="16px" />
</ButtonIcon>
<ButtonIcon
disabled={areFileTransferButtonsDisabled}
size={0}
title="Upload files"
onClick={fileTransferContext.openUploadDialog}
>
<Icons.Upload fontSize="16px" />
</ButtonIcon>
</Flex>
);
}
Loading