feat: migration filter and search bar in legacy libraries tab [FC-0097] (#2421)
Adds search bar and migration filter in legacy libraries tab
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
isLibraryKey,
|
||||
isLibraryV1Key,
|
||||
normalizeContainerType,
|
||||
parseLibraryKey,
|
||||
} from './key-utils';
|
||||
|
||||
describe('component utils', () => {
|
||||
@@ -69,6 +70,30 @@ describe('component utils', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('parseLibraryKey', () => {
|
||||
for (const [input, expected] of [
|
||||
['lib:org:lib', { org: 'org', lib: 'lib' }],
|
||||
['lib:OpenCraftX:ALPHA', { org: 'OpenCraftX', lib: 'ALPHA' }],
|
||||
] as const) {
|
||||
it(`returns '${JSON.stringify(expected)}' for learning context key '${input}'`, () => {
|
||||
expect(parseLibraryKey(input)).toStrictEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of [
|
||||
'',
|
||||
undefined,
|
||||
null,
|
||||
'not a key',
|
||||
'lb:foo',
|
||||
'lb:org:lib:html:id',
|
||||
]) {
|
||||
it(`throws an exception for library key '${input}'`, () => {
|
||||
expect(() => parseLibraryKey(input as any)).toThrow(`Invalid libraryKey: ${input}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isLibraryV1Key', () => {
|
||||
for (const [input, expected] of [
|
||||
['library-v1:AximX+L1', true],
|
||||
|
||||
@@ -17,7 +17,11 @@ export function getBlockType(usageKey: string): string {
|
||||
* Parses a library key and returns the organization and library name as an object.
|
||||
*/
|
||||
export function parseLibraryKey(libraryKey: string): { org: string, lib: string } {
|
||||
const [, org, lib] = libraryKey?.split(':') || [];
|
||||
const splitKey = libraryKey?.split(':') || [];
|
||||
if (splitKey.length !== 3) {
|
||||
throw new Error(`Invalid libraryKey: ${libraryKey}`);
|
||||
}
|
||||
const [, org, lib] = splitKey;
|
||||
if (org && lib) {
|
||||
return { org, lib };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import messages from './messages';
|
||||
|
||||
/**
|
||||
* A button that represents a filter on the search.
|
||||
* If the filter is active, the button displays the currently applied values.
|
||||
* If the filter is active and skipLabelUpdate is not true, the button displays the currently applied values.
|
||||
* So when no filter is active it may look like:
|
||||
* [ Type ▼ ]
|
||||
* Or when a filter is active and limited to two values, it may look like:
|
||||
@@ -27,6 +27,7 @@ const SearchFilterWidget: React.FC<{
|
||||
children: React.ReactNode;
|
||||
clearFilter: () => void,
|
||||
icon: React.ComponentType;
|
||||
skipLabelUpdate?: boolean;
|
||||
}> = ({ appliedFilters, ...props }) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
@@ -49,8 +50,10 @@ const SearchFilterWidget: React.FC<{
|
||||
iconAfter={ArrowDropDown}
|
||||
>
|
||||
{props.label}
|
||||
{appliedFilters.length >= 1 ? <>: {appliedFilters[0].label}</> : null}
|
||||
{appliedFilters.length > 1 ? <>, <Badge variant="secondary">+{appliedFilters.length - 1}</Badge></> : null}
|
||||
{!props.skipLabelUpdate && appliedFilters.length >= 1 ? <>: {appliedFilters[0].label}</> : null}
|
||||
{!props.skipLabelUpdate && appliedFilters.length > 1 ? (
|
||||
<>, <Badge variant="secondary">+{appliedFilters.length - 1}</Badge></>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
<ModalPopup
|
||||
|
||||
@@ -30,7 +30,7 @@ interface BaseProps {
|
||||
isMigrated?: boolean;
|
||||
migratedToKey?: string;
|
||||
migratedToTitle?: string;
|
||||
migratedToCollectionKey?: string;
|
||||
migratedToCollectionKey?: string | null;
|
||||
}
|
||||
|
||||
type Props = BaseProps & (
|
||||
|
||||
@@ -103,6 +103,15 @@ export const generateGetStudioHomeLibrariesApiResponse = () => ({
|
||||
migratedToCollectionKey: 'imported-content',
|
||||
migratedToCollectionTitle: 'Imported content',
|
||||
},
|
||||
{
|
||||
displayName: 'MBA 1',
|
||||
libraryKey: 'library-v1:MBA+1234',
|
||||
url: '/library/library-v1:MBA+1234',
|
||||
org: 'Cambridge',
|
||||
number: '1234',
|
||||
canEdit: true,
|
||||
isMigrated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
fireEvent,
|
||||
screen,
|
||||
act,
|
||||
within,
|
||||
} from '@src/testUtils';
|
||||
import messages from '../messages';
|
||||
import tabMessages from './messages';
|
||||
@@ -269,7 +270,7 @@ describe('<TabsSection />', () => {
|
||||
beforeEach(async () => {
|
||||
await axiosMock.onGet(courseApiLinkV2).reply(200, generateGetStudioCoursesApiResponseV2());
|
||||
});
|
||||
it('should switch to Legacy Libraries tab and render specific v1 library details', async () => {
|
||||
it('should switch to Legacy Libraries tab and render - search and filter should work as expected', async () => {
|
||||
await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
|
||||
await axiosMock.onGet(libraryApiLink).reply(200, generateGetStudioHomeLibrariesApiResponse());
|
||||
render();
|
||||
@@ -280,12 +281,70 @@ describe('<TabsSection />', () => {
|
||||
await user.click(librariesTab);
|
||||
|
||||
expect(librariesTab).toHaveClass('active');
|
||||
const panel = await screen.findByRole('tabpanel', { hidden: false });
|
||||
|
||||
expect(await screen.findByText(studioHomeMock.libraries[0].displayName)).toBeVisible();
|
||||
|
||||
expect(
|
||||
await screen.findByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`),
|
||||
).toBeVisible();
|
||||
|
||||
// Migration info should be displayed
|
||||
const migratedContent = generateGetStudioHomeLibrariesApiResponse().libraries[1];
|
||||
expect(await screen.findByText(migratedContent.displayName)).toBeVisible();
|
||||
const newTitleElement = await screen.findAllByText(migratedContent.migratedToTitle!);
|
||||
expect(newTitleElement[0]).toBeVisible();
|
||||
expect(newTitleElement[0]).toHaveAttribute('href', `/library/${migratedContent.migratedToKey}`);
|
||||
expect(newTitleElement[1]).toHaveAttribute(
|
||||
'href',
|
||||
`/library/${migratedContent.migratedToKey}/collection/${migratedContent.migratedToCollectionKey}`,
|
||||
);
|
||||
|
||||
// Check total count display
|
||||
expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument();
|
||||
|
||||
// Test search functionality
|
||||
const searchField = await within(panel).findByPlaceholderText('Search');
|
||||
|
||||
fireEvent.change(searchField, { target: { value: 'Legacy' } });
|
||||
// Should only show 1 result i.e. migratedContent.displayName
|
||||
expect(await within(panel).findByText('Showing 1 of 3')).toBeInTheDocument();
|
||||
expect(await within(panel).findByText(migratedContent.displayName)).toBeVisible();
|
||||
// Should not show other items.
|
||||
expect(within(panel).queryByText(
|
||||
generateGetStudioHomeLibrariesApiResponse().libraries[0].displayName,
|
||||
)).not.toBeInTheDocument();
|
||||
// reset search
|
||||
fireEvent.change(searchField, { target: { value: '' } });
|
||||
|
||||
// Test migration filter
|
||||
const filter = await within(panel).findByRole('button', { name: 'Any Migration Status' });
|
||||
await user.click(filter);
|
||||
let migratedOption = await within(panel).findByRole('checkbox', { name: 'Migrated' });
|
||||
// This should uncheck Migrated option as all options are selected by default
|
||||
await user.click(migratedOption);
|
||||
// Should only show 2 result i.e. unmigrated libraries
|
||||
expect(await within(panel).findByText('Showing 2 of 3')).toBeInTheDocument();
|
||||
// test clearing filter
|
||||
const clearFilter = await within(panel).findByRole('button', { name: 'Clear Filter' });
|
||||
await user.click(clearFilter);
|
||||
// Should show all 3 results
|
||||
expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument();
|
||||
// Open the filter again
|
||||
await user.click(filter);
|
||||
// Reload migratedOption as clearing and opening the filter again creates a new modal
|
||||
migratedOption = await within(panel).findByRole('checkbox', { name: 'Migrated' });
|
||||
const unmigratedOption = await within(panel).findByRole('checkbox', { name: 'Unmigrated' });
|
||||
// both options should be selected by default - even after clearing
|
||||
expect(migratedOption).toBeChecked();
|
||||
expect(unmigratedOption).toBeChecked();
|
||||
// Un-checking both options should reset the state to both checked.
|
||||
await user.click(unmigratedOption);
|
||||
await user.click(migratedOption);
|
||||
expect(migratedOption).toBeChecked();
|
||||
expect(unmigratedOption).toBeChecked();
|
||||
// Should show all 3 results
|
||||
expect(await within(panel).findByText('Showing 3 of 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch to Libraries tab and render specific v2 library details', async () => {
|
||||
@@ -331,17 +390,6 @@ describe('<TabsSection />', () => {
|
||||
expect(
|
||||
await screen.findByText(`${studioHomeMock.libraries[0].org} / ${studioHomeMock.libraries[0].number}`),
|
||||
).toBeVisible();
|
||||
|
||||
// Migration info should be displayed
|
||||
const migratedContent = generateGetStudioHomeLibrariesApiResponse().libraries[1];
|
||||
expect(await screen.findByText(migratedContent.displayName)).toBeVisible();
|
||||
const newTitleElement = await screen.findAllByText(migratedContent.migratedToTitle!);
|
||||
expect(newTitleElement[0]).toBeVisible();
|
||||
expect(newTitleElement[0]).toHaveAttribute('href', `/library/${migratedContent.migratedToKey}`);
|
||||
expect(newTitleElement[1]).toHaveAttribute(
|
||||
'href',
|
||||
`/library/${migratedContent.migratedToKey}/collection/${migratedContent.migratedToCollectionKey}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch to Libraries tab and render specific v2 library details ("v2 only" mode)', async () => {
|
||||
@@ -402,10 +450,11 @@ describe('<TabsSection />', () => {
|
||||
await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
|
||||
await axiosMock.onGet(libraryApiLink).reply(404);
|
||||
render();
|
||||
const user = userEvent.setup();
|
||||
await executeThunk(fetchStudioHomeData(), store.dispatch);
|
||||
|
||||
const librariesTab = await screen.findByText(tabMessages.legacyLibrariesTabTitle.defaultMessage);
|
||||
fireEvent.click(librariesTab);
|
||||
await user.click(librariesTab);
|
||||
|
||||
expect(librariesTab).toHaveClass('active');
|
||||
|
||||
|
||||
@@ -1,29 +1,127 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Row } from '@openedx/paragon';
|
||||
import { Error } from '@openedx/paragon/icons';
|
||||
import {
|
||||
ActionRow, Form, Icon, Menu, MenuItem, Pagination, Row, SearchField,
|
||||
} from '@openedx/paragon';
|
||||
import { Error, FilterList } from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import { useLibrariesV1Data } from '@src/studio-home/data/apiHooks';
|
||||
import CardItem from '@src/studio-home/card-item';
|
||||
import { useCallback, useState } from 'react';
|
||||
import SearchFilterWidget from '@src/search-manager/SearchFilterWidget';
|
||||
import messages from '../messages';
|
||||
import { sortAlphabeticallyArray } from '../utils';
|
||||
import { MigrateLegacyLibrariesAlert } from './MigrateLegacyLibrariesAlert';
|
||||
|
||||
function findInValues<T extends {}>(arr: T[] | undefined, searchValue: string) {
|
||||
return arr?.filter(o => Object.values(o).some(value => String(value).toLowerCase().includes(
|
||||
String(searchValue).toLowerCase().trim(),
|
||||
)));
|
||||
}
|
||||
|
||||
enum Filter {
|
||||
migrated = 'migrated',
|
||||
unmigrated = 'unmigrated',
|
||||
}
|
||||
|
||||
const BaseFilterState = Object.values(Filter);
|
||||
|
||||
interface MigrationFilterProps {
|
||||
filters: Filter[];
|
||||
setFilters: React.Dispatch<React.SetStateAction<Filter[]>>;
|
||||
}
|
||||
|
||||
const MigrationFilter = ({ filters, setFilters }: MigrationFilterProps) => {
|
||||
const intl = useIntl();
|
||||
const filterLabels = {
|
||||
[Filter.migrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterMigratedLabel),
|
||||
[Filter.unmigrated]: intl.formatMessage(messages.librariesV1TabMigrationFilterUnmigratedLabel),
|
||||
};
|
||||
|
||||
let label = intl.formatMessage(messages.librariesV1TabMigrationFilterLabel);
|
||||
// Set appliedFilters to empty list to indicate clear state
|
||||
let appliedFilters: { label: string }[] = [];
|
||||
if (filters.length === 1) {
|
||||
// Update label to display selected filter item, i.e., Migrated or Unmigrated
|
||||
label = filterLabels[filters[0]];
|
||||
// Only update appliedFilters if a single option is selected else show clear state.
|
||||
appliedFilters = filters.map(filter => ({ label: filterLabels[filter] }));
|
||||
}
|
||||
|
||||
const toggleFilter = useCallback((filter: Filter) => {
|
||||
setFilters((oldList: Filter[]) => {
|
||||
if (oldList.includes(filter)) {
|
||||
const newList = oldList.filter(m => m !== filter);
|
||||
if (newList.length === 0) {
|
||||
return BaseFilterState;
|
||||
}
|
||||
return newList;
|
||||
}
|
||||
// istanbul ignore next
|
||||
return [...oldList, filter];
|
||||
});
|
||||
}, [setFilters]);
|
||||
|
||||
const menuItems = useCallback(() => BaseFilterState.map((item) => (
|
||||
<MenuItem
|
||||
key={item}
|
||||
as={Form.Checkbox}
|
||||
value={item}
|
||||
onChange={() => { toggleFilter(item); }}
|
||||
>
|
||||
{filterLabels[item]}
|
||||
</MenuItem>
|
||||
)), [toggleFilter, BaseFilterState]);
|
||||
|
||||
return (
|
||||
<SearchFilterWidget
|
||||
appliedFilters={appliedFilters}
|
||||
label={label}
|
||||
clearFilter={() => setFilters(BaseFilterState)} // On clear select both migrated and unmigrated options.
|
||||
icon={FilterList}
|
||||
skipLabelUpdate
|
||||
>
|
||||
<Form.Group className="mb-0">
|
||||
<Form.CheckboxSet
|
||||
name="publish-status-filter"
|
||||
value={filters}
|
||||
>
|
||||
<Menu className="block-type-refinement-menu" style={{ boxShadow: 'none' }}>
|
||||
{menuItems()}
|
||||
</Menu>
|
||||
</Form.CheckboxSet>
|
||||
</Form.Group>
|
||||
</SearchFilterWidget>
|
||||
);
|
||||
};
|
||||
|
||||
const LibrariesTab = () => {
|
||||
const intl = useIntl();
|
||||
const { isLoading, data, isError } = useLibrariesV1Data();
|
||||
const { isPending, data, isError } = useLibrariesV1Data();
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [migrationFilter, setMigrationFilter] = useState<Filter[]>(BaseFilterState);
|
||||
|
||||
if (isLoading) {
|
||||
let filteredData = findInValues(data?.libraries, search || '') || [];
|
||||
if (migrationFilter.length === 1) {
|
||||
// filter results by migrated status
|
||||
filteredData = filteredData.filter((obj) => obj.isMigrated === (migrationFilter[0] === Filter.migrated));
|
||||
}
|
||||
const perPage = 10;
|
||||
const totalPages = Math.ceil(filteredData.length / perPage);
|
||||
const currentPageData = filteredData.slice((currentPage - 1) * perPage, currentPage * perPage);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
return (
|
||||
isError ? (
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<AlertMessage
|
||||
variant="danger"
|
||||
description={(
|
||||
@@ -33,29 +131,64 @@ const LibrariesTab = () => {
|
||||
</Row>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && (<MigrateLegacyLibrariesAlert />)}
|
||||
<div className="courses-tab">
|
||||
{sortAlphabeticallyArray(data?.libraries || []).map(({
|
||||
displayName, org, number, url, isMigrated, migratedToKey, migratedToTitle, migratedToCollectionKey,
|
||||
}) => (
|
||||
<CardItem
|
||||
key={`${org}+${number}`}
|
||||
isLibraries
|
||||
displayName={displayName}
|
||||
org={org}
|
||||
number={number}
|
||||
url={url}
|
||||
isMigrated={isMigrated}
|
||||
migratedToKey={migratedToKey}
|
||||
migratedToTitle={migratedToTitle}
|
||||
migratedToCollectionKey={migratedToCollectionKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && (<MigrateLegacyLibrariesAlert />)}
|
||||
<div className="courses-tab">
|
||||
<ActionRow className="my-3">
|
||||
<SearchField
|
||||
// istanbul ignore next
|
||||
onSubmit={() => {}}
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
className="mr-4"
|
||||
placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)}
|
||||
/>
|
||||
<MigrationFilter filters={migrationFilter} setFilters={setMigrationFilter} />
|
||||
<ActionRow.Spacer />
|
||||
{!isPending && !isError
|
||||
&& (
|
||||
<>
|
||||
{intl.formatMessage(messages.coursesPaginationInfo, {
|
||||
length: currentPageData?.length,
|
||||
total: data?.libraries.length,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ActionRow>
|
||||
{currentPageData?.map(({
|
||||
displayName, org, number, url, isMigrated, migratedToKey, migratedToTitle, migratedToCollectionKey,
|
||||
}) => (
|
||||
<CardItem
|
||||
key={`${org}+${number}`}
|
||||
isLibraries
|
||||
displayName={displayName}
|
||||
org={org}
|
||||
number={number}
|
||||
url={url}
|
||||
isMigrated={isMigrated}
|
||||
migratedToKey={migratedToKey}
|
||||
migratedToTitle={migratedToTitle}
|
||||
migratedToCollectionKey={migratedToCollectionKey}
|
||||
/>
|
||||
))}
|
||||
{
|
||||
totalPages > 1
|
||||
&& (
|
||||
<Pagination
|
||||
className="d-flex justify-content-center"
|
||||
paginationLabel="pagination navigation"
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageSelect={setCurrentPage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -106,6 +106,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Review Legacy Libraries',
|
||||
description: 'Label for the button to review legacy libraries',
|
||||
},
|
||||
librariesV1TabMigrationFilterLabel: {
|
||||
id: 'course-authoring.studio-home.libraries.tab.migration.filter.label',
|
||||
description: 'Label text for migration filter in legacy libraries tab',
|
||||
defaultMessage: 'Any Migration Status',
|
||||
},
|
||||
librariesV1TabMigrationFilterMigratedLabel: {
|
||||
id: 'course-authoring.studio-home.libraries.tab.migration.filter.item.migrated.label',
|
||||
description: 'Label text for migrated migration filter menu item in legacy libraries tab',
|
||||
defaultMessage: 'Migrated',
|
||||
},
|
||||
librariesV1TabMigrationFilterUnmigratedLabel: {
|
||||
id: 'course-authoring.studio-home.libraries.tab.migration.filter.item.unmigrated.label',
|
||||
description: 'Label text for unmigrated migration filter menu item in legacy libraries tab',
|
||||
defaultMessage: 'Unmigrated',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user