Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refined ux update a taxonomy by downloading and uploading [FC-0036] #732

Merged
Show file tree
Hide file tree
Changes from 84 commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
0c60c37
feat: add import taxonomy feature
rpenido Nov 9, 2023
c273af1
test: add api.test.js
rpenido Nov 9, 2023
047530d
test: add import button click test
rpenido Nov 9, 2023
303e7ba
test: add action.test.js
rpenido Nov 9, 2023
3955e91
test: add more tests to action.tests.js
rpenido Nov 9, 2023
8176973
test: add more tests to action.tests.js 2
rpenido Nov 9, 2023
4f31714
Merge branch 'openedx:master' into rpenido/fal-3532-import-taxonomy
rpenido Nov 10, 2023
df441fb
fix: import
rpenido Nov 10, 2023
a0e92f0
test: simplify import test
rpenido Nov 10, 2023
dc8ed9e
fix: remove undefined var
rpenido Nov 10, 2023
120154e
refactor: rename actions.js -> utils.js
rpenido Nov 10, 2023
318bbb7
revert: change in the jest.config
rpenido Nov 10, 2023
a8c2fd5
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
pomegranited Nov 16, 2023
70ac2a7
Merge branch 'openedx:master' into rpenido/fal-3532-import-taxonomy
rpenido Nov 20, 2023
adacf23
chore: trigger CD/CI
rpenido Nov 20, 2023
5983953
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
rpenido Nov 20, 2023
7756c7f
feat: import tags to existing taxonomy
rpenido Nov 24, 2023
4202c27
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
rpenido Nov 24, 2023
6bcd924
refactor: merges TaxonomyCardMenu and TaxonomyDetailMenu (#13)
rpenido Nov 30, 2023
f318dfc
refactor: improve menu organization
rpenido Dec 4, 2023
4c22a0a
feat: refined ux update a taxonomy by downloading and uploading
rpenido Dec 7, 2023
63a1487
fix: eslint
rpenido Dec 7, 2023
1110979
fix: design adjustments
rpenido Dec 7, 2023
8eac9cb
fix: more design adjustments
rpenido Dec 7, 2023
830c991
test: fix tests
rpenido Dec 7, 2023
7a0a44c
test: fix api test
rpenido Dec 7, 2023
6a55c6f
refactor: move getFileSizeToClosestByte to root utils
rpenido Dec 8, 2023
1119041
fix: invalidate taxonomyDetail query
rpenido Dec 8, 2023
f56a05d
test: add testing-library/react-hooks and fix testing
rpenido Dec 8, 2023
cb52fd7
fix: rename files
rpenido Dec 8, 2023
94f1bf7
test: fix api test
rpenido Dec 8, 2023
b63147a
test: check invalidateQueries
rpenido Dec 8, 2023
3613f22
test: improve LoadingButton tests
rpenido Dec 8, 2023
2b8d534
test: fix LoadingButton test
rpenido Dec 8, 2023
895bbc4
test: add ImportTagsWizard tests
rpenido Dec 8, 2023
538d003
test: more ImportTagsWizard tests
rpenido Dec 8, 2023
1eea5d2
fix: types
rpenido Dec 8, 2023
d91716b
feat: add typings from code review
rpenido Dec 9, 2023
244b83b
refactor: typings and return of taxonomy import api
rpenido Dec 9, 2023
c85ebdc
fix: type import
rpenido Dec 9, 2023
204b2b9
fix: types
rpenido Dec 9, 2023
707b1bd
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 9, 2023
5e0f599
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
rpenido Dec 12, 2023
f888ecf
refactor: merging conflicts
rpenido Dec 12, 2023
3c8da66
refactor: change export syntax
rpenido Dec 12, 2023
40479c1
fix: eslint
rpenido Dec 12, 2023
46e5d1b
fix: export TaxonomyDetail
rpenido Dec 12, 2023
db8ba58
fix: eslint
rpenido Dec 12, 2023
8fe8c07
Merge branch 'openedx:master' into rpenido/fal-3532-import-taxonomy
rpenido Dec 14, 2023
aa5fc4f
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 15, 2023
9fce34b
fix: TaxonomyContext types
rpenido Dec 15, 2023
1440215
fix: TaxonomyContext types
rpenido Dec 15, 2023
89f8116
feat: add toast and error message
rpenido Dec 15, 2023
5dd25a2
fix: layout alert message
rpenido Dec 15, 2023
676a636
fix: types and lint
rpenido Dec 15, 2023
23c28af
fix: lint
rpenido Dec 15, 2023
ad69e11
fix: error message
rpenido Dec 18, 2023
f6cfcf3
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 18, 2023
2d3abe8
fix: use scss utility
rpenido Dec 18, 2023
cfa01b6
style: add PR in comment
rpenido Dec 18, 2023
e5b8e58
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
rpenido Dec 19, 2023
fafcb9b
chore: rebasing
rpenido Dec 19, 2023
e80194f
fix: types
rpenido Dec 19, 2023
e0f1a40
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 19, 2023
8bcbb64
fix: missing }
rpenido Dec 19, 2023
1e0dde3
fix: remove typo
rpenido Dec 19, 2023
7cae57b
test: cleaning test removing useMutation mock
rpenido Dec 19, 2023
1002fc6
style: cleaning code and fix lint
rpenido Dec 19, 2023
a33aa8a
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 19, 2023
7900876
fix: import taxonomy type
rpenido Dec 19, 2023
46b7b3e
revert: changes in package-lock.json
rpenido Dec 20, 2023
e54fd45
refactor: rename close -> onClose
rpenido Dec 20, 2023
a51bb40
test: change to getByText
rpenido Dec 20, 2023
fdc74c7
Merge branch 'master' into rpenido/fal-3532-import-taxonomy
rpenido Dec 21, 2023
9a35f1a
Merge branch 'rpenido/fal-3532-import-taxonomy' into rpenido/fal-3539…
rpenido Dec 21, 2023
ef20d55
fix: add a div over the dialog to prevent the user to interact while …
rpenido Dec 21, 2023
65ca5a9
fix: add title to dialog to remove console warning
rpenido Dec 26, 2023
ff234b4
fix: add title property to Stepper.Step to fix warning
rpenido Dec 26, 2023
72d82d6
fix: remove warning when toastMessage = null
rpenido Dec 26, 2023
3e5931d
Merge branch 'master' into rpenido/fal-3539-refined-ux-update-a-taxon…
rpenido Jan 8, 2024
67e0f7b
fix: merging errors
rpenido Jan 8, 2024
a6e1a8f
fix: typo
rpenido Jan 8, 2024
9321f4e
test: change .toThrow => .not.toBeInDocument
rpenido Jan 8, 2024
e427e82
fix: reverting some minor changes
rpenido Jan 8, 2024
2e81a5c
fix: remove console warnings
rpenido Jan 10, 2024
ae9657d
chore: trigger CI
rpenido Jan 10, 2024
a86f597
refactor: getFileSizeToClosestByte
rpenido Jan 12, 2024
71b025e
test: remove waitFor and change e.click() to fireEvent.click(e)
rpenido Jan 12, 2024
2637165
fix: remove disabled from dialog
rpenido Jan 12, 2024
fd897b3
Merge branch 'master' into rpenido/fal-3539-refined-ux-update-a-taxon…
rpenido Jan 12, 2024
7400aa4
fix: remove old test file
rpenido Jan 12, 2024
bbb0b19
refactor: LoadingButton
rpenido Jan 12, 2024
e82a49c
fix: alertProps type
rpenido Jan 12, 2024
4e42254
style: fix eslint
rpenido Jan 12, 2024
971de66
fix: remove wrong comment
rpenido Jan 12, 2024
2c05a81
test: fix LoadingButton tests
rpenido Jan 15, 2024
f0af395
fix: add catch to LoadingButton click to fix test
rpenido Jan 15, 2024
dd3e972
fix: change requested modular-learning#176
rpenido Jan 15, 2024
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
4 changes: 4 additions & 0 deletions src/assets/scss/_utilities.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
color: $black;
}

.h-200px {
height: 200px;
}

.mw-300px {
max-width: 300px;
}
2 changes: 1 addition & 1 deletion src/files-and-videos/files-page/FileInfoModalSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '@edx/paragon';
import { ContentCopy, InfoOutline } from '@edx/paragon/icons';

import { getFileSizeToClosestByte } from '../generic/utils';
import { getFileSizeToClosestByte } from '../../utils';
import messages from './messages';

const FileInfoModalSidebar = ({
Expand Down
2 changes: 1 addition & 1 deletion src/files-and-videos/files-page/FilesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
FileTable,
ThumbnailColumn,
} from '../generic';
import { getFileSizeToClosestByte } from '../generic/utils';
import { getFileSizeToClosestByte } from '../../utils';
import FileThumbnail from './FileThumbnail';
import FileInfoModalSidebar from './FileInfoModalSidebar';

Expand Down
21 changes: 1 addition & 20 deletions src/files-and-videos/generic/utils.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,4 @@
export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => {
if (fileSize > 1000) {
const updatedSize = fileSize / 1000;
const incrementNumberOfDivides = numberOfDivides + 1;
return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides);
}
const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2);
switch (numberOfDivides) {
case 1:
return `${fileSizeFixedDecimal} KB`;
case 2:
return `${fileSizeFixedDecimal} MB`;
case 3:
return `${fileSizeFixedDecimal} GB`;
default:
return `${fileSizeFixedDecimal} B`;
}
};

export const sortFiles = (files, sortType) => {
export const sortFiles = (files, sortType) => { // eslint-disable-line import/prefer-default-export
const [sort, direction] = sortType.split(',');
let sortedFiles;
if (sort === 'displayName') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Stack } from '@edx/paragon';
import { injectIntl, FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getFileSizeToClosestByte } from '../../generic/utils';
import { getFileSizeToClosestByte } from '../../../utils';
import { getFormattedDuration } from '../data/utils';
import messages from './messages';

Expand Down
67 changes: 67 additions & 0 deletions src/generic/loading-button/LoadingButton.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import { render } from '@testing-library/react';

import LoadingButton from '.';

const buttonTitle = 'Button Title';

const RootWrapper = (onClick) => (
<LoadingButton onClick={onClick}>
{buttonTitle}
</LoadingButton>
);

describe('<LoadingButton />', () => {
it('renders the title and doesnt handle the spinner initially', () => {
const { getByText, queryByTestId } = render(RootWrapper(() => { }));
const titleElement = getByText(buttonTitle);
expect(titleElement).toBeInTheDocument();
expect(queryByTestId('button-loading-spinner')).not.toBeInTheDocument();
});

it('doesnt render the spinner initially without onClick function', () => {
const { getByRole, getByText, queryByTestId } = render(RootWrapper());
const titleElement = getByText(buttonTitle);
expect(titleElement).toBeInTheDocument();
expect(queryByTestId('button-loading-spinner')).not.toBeInTheDocument();
const buttonElement = getByRole('button');
buttonElement.click();
expect(queryByTestId('button-loading-spinner')).not.toBeInTheDocument();
});

it('renders the spinner correctly', () => {
const longFunction = () => new Promise((resolve) => {
setTimeout(resolve, 1000);
});
const { getByRole, getByText, getByTestId } = render(RootWrapper(longFunction));
const buttonElement = getByRole('button');
buttonElement.click();
const spinnerElement = getByTestId('button-loading-spinner');
expect(spinnerElement).toBeInTheDocument();
const titleElement = getByText(buttonTitle);
expect(titleElement).toBeInTheDocument();
expect(buttonElement).toBeDisabled();
setTimeout(() => {
expect(buttonElement).toBeEnabled();
expect(spinnerElement).not.toBeInTheDocument();
}, 2000);
});

it('renders the spinner correctly even with error', () => {
const longFunction = () => new Promise((_resolve, reject) => {
setTimeout(reject, 1000);
});
const { getByRole, getByText, getByTestId } = render(RootWrapper(longFunction));
const buttonElement = getByRole('button');
buttonElement.click();
const spinnerElement = getByTestId('button-loading-spinner');
expect(spinnerElement).toBeInTheDocument();
const titleElement = getByText(buttonTitle);
expect(titleElement).toBeInTheDocument();
expect(buttonElement).toBeDisabled();
setTimeout(() => {
expect(buttonElement).toBeEnabled();
expect(spinnerElement).not.toBeInTheDocument();
}, 2000);
});
});
61 changes: 61 additions & 0 deletions src/generic/loading-button/index.jsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a component like this in Paragon called StatefulButton.

I think the use case here could be covered by that component.

Copy link
Contributor Author

@rpenido rpenido Jan 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit different. The StatefulButton shows the state, but the state resides outside the component. The LoadingButton also handles the state inside according to the onClick call, abstracting this from the developer.

I changed the LoadingButton to use the StatefulButton inside here bbb0b19. If you don't agree to add another component let me know, and I will change it to a standard button to avoid more boiler plate in this already big component.

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @ts-check
import React, { useState } from 'react';

import {
Button,
Spinner,
Stack,
} from '@edx/paragon';

/**
* A button that shows a loading spinner when clicked.
* @param {object} props
* @param {React.ReactNode=} props.children
* @param {boolean=} props.disabled
* @param {function=} props.onClick
* @returns {JSX.Element}
*/
const LoadingButton = ({
onClick,
children,
disabled,
...props
}) => {
const [isLoading, setIsLoading] = useState(false);

const loadingOnClick = async (e) => {
if (!onClick) {
return;
}

setIsLoading(true);
try {
await onClick(e);
} finally {
setIsLoading(false);
}
};

return (
<Button
{...props}
disabled={!!isLoading || disabled}
onClick={loadingOnClick}
>
<Stack gap={2} direction="horizontal">
{children}
{isLoading && <Spinner size="sm" animation="border" data-testid="button-loading-spinner" />}
</Stack>
</Button>
);
};

LoadingButton.propTypes = {
...Button.propTypes,
};

LoadingButton.defaultProps = {
...Button.defaultProps,
};

export default LoadingButton;
35 changes: 27 additions & 8 deletions src/taxonomy/TaxonomyLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,51 @@
// @ts-check
import React, { useMemo, useState } from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { Toast } from '@edx/paragon';

import AlertMessage from '../generic/alert-message';
import Header from '../header';
import { TaxonomyContext } from './common/context';
import messages from './messages';

const TaxonomyLayout = () => {
const intl = useIntl();
// Use `setToastMessage` to show the toast.
const [toastMessage, setToastMessage] = useState(null);
// Use `setToastMessage` to show the alert.
const [alertProps, setAlertProps] = useState(null);

const context = useMemo(() => ({
toastMessage, setToastMessage,
toastMessage, setToastMessage, alertProps, setAlertProps,
}), []);

return (
<TaxonomyContext.Provider value={context}>
<div className="bg-light-400">
<Header isHiddenMainMenu />
{ alertProps && (
<AlertMessage
data-testid="taxonomy-alert"
className="mb-0"
dismissible
closeLabel={intl.formatMessage(messages.taxonomyDismissLabel)}
onClose={() => setAlertProps(null)}
// @ts-ignore ToDo: fix object spread type error
{...alertProps}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this todo intentional? Is it intended to be fixed in a future PR?

Copy link
Contributor Author

@rpenido rpenido Jan 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. I was having trouble fixing this type check before. It was happening because I was missing the state type.
Fixed e82a49c

/>
)}
<Outlet />
<StudioFooter />
<Toast
show={toastMessage !== null}
onClose={() => setToastMessage(null)}
data-testid="taxonomy-toast"
>
{toastMessage}
</Toast>
{toastMessage && (
<Toast
onClose={() => setToastMessage(null)}

Check warning on line 43 in src/taxonomy/TaxonomyLayout.jsx

View check run for this annotation

Codecov / codecov/patch

src/taxonomy/TaxonomyLayout.jsx#L43

Added line #L43 was not covered by tests
data-testid="taxonomy-toast"
>
{toastMessage}
</Toast>
)}
</div>
<ScrollRestoration />
</TaxonomyContext.Provider>
Expand Down
73 changes: 55 additions & 18 deletions src/taxonomy/TaxonomyLayout.test.jsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
import React from 'react';
import React, { useContext } from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, act } from '@testing-library/react';
import { render } from '@testing-library/react';

import initializeStore from '../store';
import { TaxonomyContext } from './common/context';
import TaxonomyLayout from './TaxonomyLayout';

let store;
const toastMessage = 'Hello, this is a toast!';
const alertErrorTitle = 'Error title';
const alertErrorDescription = 'Error description';

const MockChildComponent = () => {
const { setToastMessage, setAlertProps } = useContext(TaxonomyContext);

return (
<div data-testid="mock-content">
<button
type="button"
onClick={() => setToastMessage(toastMessage)}
data-testid="taxonomy-show-toast"
>
Show Toast
</button>
<button
type="button"
onClick={() => setAlertProps({ title: alertErrorTitle, description: alertErrorDescription })}
data-testid="taxonomy-show-alert"
>
Show Alert
</button>
</div>
);
};

jest.mock('../header', () => jest.fn(() => <div data-testid="mock-header" />));
jest.mock('@edx/frontend-component-footer', () => ({
StudioFooter: jest.fn(() => <div data-testid="mock-footer" />),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Outlet: jest.fn(() => <div data-testid="mock-content" />),
Outlet: () => <MockChildComponent />,
ScrollRestoration: jest.fn(() => <div />),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn((initial) => {
if (initial === null) {
return [toastMessage, jest.fn()];
}
return [initial, jest.fn()];
}),
}));

const RootWrapper = () => (
<AppProvider store={store}>
Expand All @@ -49,18 +67,37 @@ describe('<TaxonomyLayout />', async () => {
store = initializeStore();
});

it('should render page correctly', async () => {
it('should render page correctly', () => {
const { getByTestId } = render(<RootWrapper />);
expect(getByTestId('mock-header')).toBeInTheDocument();
expect(getByTestId('mock-content')).toBeInTheDocument();
expect(getByTestId('mock-footer')).toBeInTheDocument();
});

it('should show toast', async () => {
it('should show toast', () => {
const { getByTestId, getByText } = render(<RootWrapper />);
act(() => {
expect(getByTestId('taxonomy-toast')).toBeInTheDocument();
expect(getByText(toastMessage)).toBeInTheDocument();
});
const button = getByTestId('taxonomy-show-toast');
button.click();
expect(getByTestId('taxonomy-toast')).toBeInTheDocument();
expect(getByText(toastMessage)).toBeInTheDocument();
});

it('should show alert', () => {
const {
getByTestId,
getByText,
getByRole,
queryByTestId,
} = render(<RootWrapper />);

const button = getByTestId('taxonomy-show-alert');
button.click();
expect(getByTestId('taxonomy-alert')).toBeInTheDocument();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of button.click etc, you should ideally use fireEvent.click(button) or userEvent.click(button)

Suggested change
button.click();
fireEvent.click(button);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 71b025e

expect(getByText(alertErrorTitle)).toBeInTheDocument();
expect(getByText(alertErrorDescription)).toBeInTheDocument();

const closeAlertButton = getByRole('button', { name: 'Dismiss' });
closeAlertButton.click();
expect(queryByTestId('taxonomy-alert')).not.toBeInTheDocument();
});
});
5 changes: 1 addition & 4 deletions src/taxonomy/TaxonomyListPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,14 @@ const context = {
toastMessage: null,
setToastMessage: jest.fn(),
};

const queryClient = new QueryClient();

const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyContext.Provider value={context}>
<QueryClientProvider client={queryClient}>
<TaxonomyListPage intl={injectIntl} />
</QueryClientProvider>
<TaxonomyListPage intl={injectIntl} />
</TaxonomyContext.Provider>
</QueryClientProvider>
</IntlProvider>
Expand Down
10 changes: 9 additions & 1 deletion src/taxonomy/common/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';

/**
* @typedef AlertProps
* @type {Object}
* @property {React.ReactNode} title - title of the alert.
* @property {React.ReactNode} description - description of the alert.
*/
export const TaxonomyContext = React.createContext({
toastMessage: /** @type{null|string} */ (null),
setToastMessage: /** @type{null|function} */ (null),
setToastMessage: /** @type{null|React.Dispatch<React.SetStateAction<null|string>>} */ (null),
alertProps: /** @type{null|AlertProps} */ (null),
setAlertProps: /** @type{null|React.Dispatch<React.SetStateAction<null|AlertProps>>} */ (null),
});
Loading