diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index 60e4fe4ec..cd97c2668 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -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], diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index a98bce9ab..88dbb14bf 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -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 }; } diff --git a/src/search-manager/SearchFilterWidget.tsx b/src/search-manager/SearchFilterWidget.tsx index cdcd1bf71..c2a6a3536 100644 --- a/src/search-manager/SearchFilterWidget.tsx +++ b/src/search-manager/SearchFilterWidget.tsx @@ -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 ? <>, +{appliedFilters.length - 1} : null} + {!props.skipLabelUpdate && appliedFilters.length >= 1 ? <>: {appliedFilters[0].label} : null} + {!props.skipLabelUpdate && appliedFilters.length > 1 ? ( + <>, +{appliedFilters.length - 1} + ) : null} ({ 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, + }, ], }); diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx index 72a4b16d6..c63aac248 100644 --- a/src/studio-home/tabs-section/TabsSection.test.tsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -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('', () => { 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('', () => { 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('', () => { 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('', () => { 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'); diff --git a/src/studio-home/tabs-section/libraries-tab/index.tsx b/src/studio-home/tabs-section/libraries-tab/index.tsx index b23b6cdf4..5ac5c9d26 100644 --- a/src/studio-home/tabs-section/libraries-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-tab/index.tsx @@ -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(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>; +} + +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) => ( + { toggleFilter(item); }} + > + {filterLabels[item]} + + )), [toggleFilter, BaseFilterState]); + + return ( + setFilters(BaseFilterState)} // On clear select both migrated and unmigrated options. + icon={FilterList} + skipLabelUpdate + > + + + + {menuItems()} + + + + + ); +}; + const LibrariesTab = () => { const intl = useIntl(); - const { isLoading, data, isError } = useLibrariesV1Data(); + const { isPending, data, isError } = useLibrariesV1Data(); + const [currentPage, setCurrentPage] = useState(1); + const [search, setSearch] = useState(''); + const [migrationFilter, setMigrationFilter] = useState(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 ( ); } - return ( - isError ? ( + + if (isError) { + return ( { )} /> - ) : ( - <> - {getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && ()} -
- {sortAlphabeticallyArray(data?.libraries || []).map(({ - displayName, org, number, url, isMigrated, migratedToKey, migratedToTitle, migratedToCollectionKey, - }) => ( - - ))} -
- - ) + ); + } + + return ( + <> + {getConfig().ENABLE_LEGACY_LIBRARY_MIGRATOR === 'true' && ()} +
+ + {}} + onChange={setSearch} + value={search} + className="mr-4" + placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)} + /> + + + {!isPending && !isError + && ( + <> + {intl.formatMessage(messages.coursesPaginationInfo, { + length: currentPageData?.length, + total: data?.libraries.length, + })} + + )} + + {currentPageData?.map(({ + displayName, org, number, url, isMigrated, migratedToKey, migratedToTitle, migratedToCollectionKey, + }) => ( + + ))} + { + totalPages > 1 + && ( + + ) + } +
+ ); }; diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index db60d6536..1cb1c715d 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -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;