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:
Navin Karkera
2025-09-25 22:30:26 +05:30
committed by GitHub
parent 25160347b3
commit cffc4d77c9
8 changed files with 286 additions and 48 deletions

View File

@@ -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],

View File

@@ -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 };
}

View File

@@ -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 ? <>,&nbsp;<Badge variant="secondary">+{appliedFilters.length - 1}</Badge></> : null}
{!props.skipLabelUpdate && appliedFilters.length >= 1 ? <>: {appliedFilters[0].label}</> : null}
{!props.skipLabelUpdate && appliedFilters.length > 1 ? (
<>,&nbsp;<Badge variant="secondary">+{appliedFilters.length - 1}</Badge></>
) : null}
</Button>
</div>
<ModalPopup

View File

@@ -30,7 +30,7 @@ interface BaseProps {
isMigrated?: boolean;
migratedToKey?: string;
migratedToTitle?: string;
migratedToCollectionKey?: string;
migratedToCollectionKey?: string | null;
}
type Props = BaseProps & (

View File

@@ -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,
},
],
});

View File

@@ -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');

View File

@@ -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>
</>
);
};

View File

@@ -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;