Skip to content

Commit

Permalink
feat: refined ux update a taxonomy by downloading and uploading [FC-0…
Browse files Browse the repository at this point in the history
…036] (#732)

This PR improves the import tags functionality for existing taxonomies implemented at #675.

Co-authored-by: Jillian <[email protected]>
Co-authored-by: Braden MacDonald <[email protected]>
  • Loading branch information
3 people authored Jan 16, 2024
1 parent 1fef358 commit b59ecaf
Show file tree
Hide file tree
Showing 26 changed files with 1,190 additions and 460 deletions.
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
2 changes: 1 addition & 1 deletion src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx
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
76 changes: 76 additions & 0 deletions src/generic/loading-button/LoadingButton.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import {
act,
fireEvent,
render,
} from '@testing-library/react';

import LoadingButton from '.';

const buttonTitle = 'Button Title';

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

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

expect(container.getElementsByClassName('icon-spin').length).toBe(0);
});

it('doesnt render the spinner without onClick function', () => {
const { container, getByRole, getByText } = render(RootWrapper());
const titleElement = getByText(buttonTitle);
expect(titleElement).toBeInTheDocument();
expect(container.getElementsByClassName('icon-spin').length).toBe(0);
fireEvent.click(getByRole('button'));
expect(container.getElementsByClassName('icon-spin').length).toBe(0);
});

it('renders the spinner correctly', async () => {
let resolver;
const longFunction = () => new Promise((resolve) => {
resolver = resolve;
});
const { container, getByRole, getByText } = render(RootWrapper(longFunction));
const buttonElement = getByRole('button');
fireEvent.click(buttonElement);
expect(container.getElementsByClassName('icon-spin').length).toBe(1);
expect(getByText(buttonTitle)).toBeInTheDocument();
// StatefulButton only sets aria-disabled (not disabled) when the state is pending
// expect(buttonElement).toBeDisabled();
expect(buttonElement).toHaveAttribute('aria-disabled', 'true');

await act(async () => { resolver(); });

expect(buttonElement).not.toHaveAttribute('aria-disabled', 'true');
expect(container.getElementsByClassName('icon-spin').length).toBe(0);
});

it('renders the spinner correctly even with error', async () => {
let rejecter;
const longFunction = () => new Promise((_resolve, reject) => {
rejecter = reject;
});
const { container, getByRole, getByText } = render(RootWrapper(longFunction));
const buttonElement = getByRole('button');

fireEvent.click(buttonElement);

expect(container.getElementsByClassName('icon-spin').length).toBe(1);
expect(getByText(buttonTitle)).toBeInTheDocument();
// StatefulButton only sets aria-disabled (not disabled) when the state is pending
// expect(buttonElement).toBeDisabled();
expect(buttonElement).toHaveAttribute('aria-disabled', 'true');

await act(async () => { rejecter(new Error('error')); });

// StatefulButton only sets aria-disabled (not disabled) when the state is pending
// expect(buttonElement).toBeEnabled();
expect(buttonElement).not.toHaveAttribute('aria-disabled', 'true');
expect(container.getElementsByClassName('icon-spin').length).toBe(0);
});
});
72 changes: 72 additions & 0 deletions src/generic/loading-button/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @ts-check
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
StatefulButton,
} from '@edx/paragon';
import PropTypes from 'prop-types';

/**
* A button that shows a loading spinner when clicked.
* @param {object} props
* @param {string} props.label
* @param {function=} props.onClick
* @param {boolean=} props.disabled
* @returns {JSX.Element}
*/
const LoadingButton = ({
label,
onClick,
disabled,
}) => {
const [state, setState] = useState('');
// This is used to prevent setting the isLoading state after the component has been unmounted.
const componentMounted = useRef(true);

useEffect(() => () => {
componentMounted.current = false;
}, []);

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

setState('pending');
try {
await onClick(e);
} catch (err) {
// Do nothing
} finally {
if (componentMounted.current) {
setState('');
}
}
}, [componentMounted, onClick]);

return (
<StatefulButton
disabledStates={disabled ? [state] : ['pending'] /* StatefulButton doesn't support disabled prop */}
onClick={loadingOnClick}
labels={{ default: label }}
state={state}
/>
);
};

LoadingButton.propTypes = {
label: PropTypes.string.isRequired,
onClick: PropTypes.func,
disabled: PropTypes.bool,
};

LoadingButton.defaultProps = {
onClick: undefined,
disabled: undefined,
};

export default LoadingButton;
37 changes: 28 additions & 9 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);
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
// Use `setToastMessage` to show the alert.
const [alertProps, setAlertProps] = useState(/** @type {null|import('./common/context').AlertProps} */ (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)}
{...alertProps}
/>
)}
<Outlet />
<StudioFooter />
<Toast
show={toastMessage !== null}
onClose={() => setToastMessage(null)}
data-testid="taxonomy-toast"
>
{toastMessage}
</Toast>
{toastMessage && (
<Toast
show
onClose={() => setToastMessage(null)}
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();
expect(getByText(alertErrorTitle)).toBeInTheDocument();
expect(getByText(alertErrorDescription)).toBeInTheDocument();

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

const queryClient = new QueryClient();

const RootWrapper = () => (
Expand Down
Loading

0 comments on commit b59ecaf

Please sign in to comment.