[Teak] backport #1949, #1999 and #2002 (#2006)

* feat: select component and show sidebar on edit  (#1949)

Select component that is being edited in library and show its sidebar. Also fixes issue with children component listing in library unit page

(cherry picked from commit 08ac1c0c4d)

* fix: search text flickering (#1999)

Fix flickering issue in search field.

(cherry picked from commit 6f3b7ab962)

* feat: open collection or unit page on double click only (#2002)

Opens collection or unit page only on double click.

(cherry picked from commit 503642be8c)
This commit is contained in:
Navin Karkera
2025-05-21 22:20:16 +00:00
committed by GitHub
parent 1919eb4845
commit 403dfa1e6b
13 changed files with 94 additions and 34 deletions

View File

@@ -370,7 +370,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.change(searchBox, { target: { value: 'words to find' } });
// Default sort option changes to "Most Relevant"
expect(screen.getAllByText('Most Relevant').length).toEqual(2);
expect((await screen.findAllByText('Most Relevant')).length).toEqual(2);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"sort":[]'),

View File

@@ -48,7 +48,8 @@ const CollectionInfo = () => {
if (componentPickerMode) {
setCollectionId(collectionId);
} else {
navigateTo({ collectionId });
/* istanbul ignore next */
navigateTo({ collectionId, doubleClicked: true });
}
}, [componentPickerMode, navigateTo]);

View File

@@ -315,7 +315,7 @@ describe('<LibraryCollectionPage />', () => {
fireEvent.change(searchBox, { target: { value: 'words to find' } });
// Default sort option changes to "Most Relevant"
expect(screen.getAllByText('Most Relevant').length).toEqual(2);
expect((await screen.findAllByText('Most Relevant')).length).toEqual(2);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"sort":[]'),

View File

@@ -22,7 +22,7 @@ type BaseCardProps = {
tags: ContentHitTags;
actions: React.ReactNode;
hasUnpublishedChanges?: boolean;
onSelect: () => void;
onSelect: (e?: React.MouseEvent) => void;
selected?: boolean;
};

View File

@@ -114,7 +114,7 @@ type CollectionCardProps = {
const CollectionCard = ({ hit } : CollectionCardProps) => {
const { componentPickerMode } = useComponentPickerContext();
const { showOnlyPublished } = useLibraryContext();
const { showOnlyPublished, setCollectionId } = useLibraryContext();
const { openCollectionInfoSidebar, sidebarComponentInfo } = useSidebarContext();
const {
@@ -136,11 +136,15 @@ const CollectionCard = ({ hit } : CollectionCardProps) => {
&& sidebarComponentInfo.id === collectionId;
const { navigateTo } = useLibraryRoutes();
const openCollection = useCallback(() => {
const openCollection = useCallback((e?: React.MouseEvent) => {
openCollectionInfoSidebar(collectionId);
const doubleClicked = (e?.detail || 0) > 1;
if (!componentPickerMode) {
navigateTo({ collectionId });
navigateTo({ collectionId, doubleClicked });
} else if (doubleClicked) {
/* istanbul ignore next */
setCollectionId(collectionId);
}
}, [collectionId, navigateTo, openCollectionInfoSidebar]);

View File

@@ -11,6 +11,13 @@ import { ContentHit } from '../../search-manager';
import ComponentCard from './ComponentCard';
import { PublishStatus } from '../../search-manager/data/api';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useNavigate: () => mockNavigate,
}));
const contentHit: ContentHit = {
id: '1',
usageKey: 'lb:org1:demolib:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d',
@@ -41,6 +48,8 @@ const contentHit: ContentHit = {
const libraryId = 'lib:org1:Demo_Course';
const render = () => baseRender(<ComponentCard hit={contentHit} />, {
path: '/library/:libraryId',
params: { libraryId },
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId}>
{ children }
@@ -104,4 +113,24 @@ describe('<ComponentCard />', () => {
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
});
});
it('should select component on clicking edit menu option', async () => {
initializeMocks();
render();
// Open menu
const menu = await screen.findByTestId('component-card-menu-toggle');
expect(menu).toBeInTheDocument();
fireEvent.click(menu);
// Click copy to clipboard
const editOption = await screen.findByRole('button', { name: 'Edit' });
expect(editOption).toBeInTheDocument();
fireEvent.click(editOption);
// Verify that the url is updated to component url i.e. component is selected
expect(mockNavigate).toHaveBeenCalledWith({
pathname: `/library/${libraryId}/component/${contentHit.usageKey}`,
search: '',
});
});
});

View File

@@ -90,6 +90,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
});
};
const handleEdit = useCallback(() => {
navigateTo({ componentId: usageKey });
openComponentInfoSidebar(usageKey);
openComponentEditor(usageKey);
}, [usageKey]);
const scheduleJumpToCollection = useRunOnNextRender(() => {
// TODO: Ugly hack to make sure sidebar shows add to collection section
// This needs to run after all changes to url takes place to avoid conflicts.
@@ -119,7 +125,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
<Dropdown.Item {...(canEdit ? { onClick: handleEdit } : { disabled: true })}>
<FormattedMessage {...messages.menuEdit} />
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick}>

View File

@@ -204,12 +204,12 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
const { navigateTo } = useLibraryRoutes();
const openContainer = useCallback(() => {
const openContainer = useCallback((e?: React.MouseEvent) => {
if (itemType === 'unit') {
openUnitInfoSidebar(unitId);
setUnitId(unitId);
if (!componentPickerMode) {
navigateTo({ unitId });
navigateTo({ unitId, doubleClicked: (e?.detail || 0) > 1 });
}
}
}, [unitId, itemType, openUnitInfoSidebar, navigateTo]);

View File

@@ -19,23 +19,28 @@ mockGetContainerChildren.applyMock();
const { libraryId } = mockContentLibrary;
const { containerId } = mockGetContainerMetadata;
const render = (showOnlyPublished: boolean = false) => baseRender(<UnitInfo />, {
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
<SidebarProvider
initialSidebarComponentInfo={{
id: containerId,
type: SidebarBodyComponentId.UnitInfo,
}}
const render = (showOnlyPublished: boolean = false) => {
const params: { libraryId: string, unitId?: string } = { libraryId, unitId: containerId };
return baseRender(<UnitInfo />, {
path: '/library/:libraryId/:unitId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
{children}
</SidebarProvider>
</LibraryProvider>
),
});
<SidebarProvider
initialSidebarComponentInfo={{
id: containerId,
type: SidebarBodyComponentId.UnitInfo,
}}
>
{children}
</SidebarProvider>
</LibraryProvider>
),
});
};
let axiosMock: MockAdapter;
let mockShowToast;

View File

@@ -142,4 +142,13 @@ describe('library data API', () => {
await api.removeLibraryContainerChildren(containerId, ['test']);
expect(axiosMock.history.delete[0].url).toEqual(url);
});
it('getContentLibraryV2List', async () => {
const url = api.getContentLibraryV2ListApiUrl();
axiosMock.onGet(url).reply(200, { some: 'data' });
await api.getContentLibraryV2List({ type: 'complex' });
expect(axiosMock.history.get[0].url).toEqual(url);
});
});

View File

@@ -49,6 +49,7 @@ export type NavigateToData = {
collectionId?: string,
contentType?: ContentType,
unitId?: string,
doubleClicked?: boolean,
};
export type LibraryRoutesData = {
@@ -80,6 +81,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
collectionId,
unitId,
contentType,
doubleClicked,
}: NavigateToData = {}) => {
const {
collectionId: urlCollectionId,
@@ -125,7 +127,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
} else if (insideCollections) {
// We're inside the Collections tab,
route = (
(collectionId && collectionId === (urlCollectionId || urlSelectedItemId))
(collectionId && doubleClicked)
// now open the previously-selected collection,
? ROUTES.COLLECTION
// or stay there to list all collections, or a selected collection.
@@ -142,7 +144,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
} else if (insideUnits) {
// We're inside the units tab,
route = (
(unitId && unitId === (urlUnitId || urlSelectedItemId))
(unitId && doubleClicked)
// now open the previously-selected unit,
? ROUTES.UNIT
// or stay there to list all units, or a selected unit.
@@ -156,10 +158,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
// We're inside the All Content tab, so stay there,
// and select a component.
route = ROUTES.COMPONENT;
} else if (collectionId && collectionId === (urlCollectionId || urlSelectedItemId)) {
} else if (collectionId && doubleClicked) {
// now open the previously-selected collection
route = ROUTES.COLLECTION;
} else if (unitId && unitId === (urlUnitId || urlSelectedItemId)) {
} else if (unitId && doubleClicked) {
// now open the previously-selected unit
route = ROUTES.UNIT;
} else {

View File

@@ -238,9 +238,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
const [hidePreviewFor, setHidePreviewFor] = useState<string | null>(null);
const { showToast } = useContext(ToastContext);
const { readOnly, showOnlyPublished } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const unitId = sidebarComponentInfo?.id;
const { unitId, readOnly, showOnlyPublished } = useLibraryContext();
const { openAddContentSidebar } = useSidebarContext();

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { SearchField } from '@openedx/paragon';
import { debounce } from 'lodash';
import messages from './messages';
import { useSearchContext } from './SearchManager';
@@ -17,10 +18,15 @@ const SearchKeywordsField: React.FC<{
const defaultPlaceholder = usageKey ? messages.clearUsageKeyToSearch : messages.inputPlaceholder;
const { placeholder = intl.formatMessage(defaultPlaceholder) } = props;
const handleSearch = React.useCallback(
debounce((term) => setSearchKeywords(term.trim()), 400),
[searchKeywords],
);// Perform search after 500ms
return (
<SearchField.Advanced
onSubmit={setSearchKeywords}
onChange={setSearchKeywords}
onChange={handleSearch}
onClear={() => setSearchKeywords('')}
value={searchKeywords}
className={props.className}