From ab0e0d71c1308a481550f9566bfb17033374abdd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 1 May 2025 22:28:09 +0000 Subject: [PATCH 01/37] refactor: remove custom order function from course libraries list (#1865) (#1888) (cherry picked from commit bc18fffedf4be5be45a29d8c921840af86dbaab7) --- src/course-libraries/ReviewTabContent.tsx | 24 ++--------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index 1c756c5ca9..0c22815a3a 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -14,9 +14,7 @@ import { useToggle, } from '@openedx/paragon'; -import { - tail, keyBy, orderBy, merge, omitBy, -} from 'lodash'; +import { tail, keyBy } from 'lodash'; import { useQueryClient } from '@tanstack/react-query'; import { Loop, Warning } from '@openedx/paragon/icons'; import messages from './messages'; @@ -116,7 +114,6 @@ const ComponentReviewList = ({ hits: downstreamInfo, isLoading: isIndexDataLoading, searchKeywords, - searchSortOrder, hasError, hasNextPage, isFetchingNextPage, @@ -143,10 +140,6 @@ const ComponentReviewList = ({ () => keyBy(outOfSyncComponents, 'downstreamUsageKey'), [outOfSyncComponents], ); - const downstreamInfoByKey = useMemo( - () => keyBy(downstreamInfo, 'usageKey'), - [downstreamInfo], - ); const queryClient = useQueryClient(); useEffect(() => { @@ -236,19 +229,6 @@ const ComponentReviewList = ({ } }, [blockData]); - const orderInfo = useMemo(() => { - if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) { - return downstreamInfo; - } - if (isIndexDataLoading) { - return []; - } - let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey); - merged = omitBy(merged, (o) => !o.displayName); - const ordered = orderBy(Object.values(merged), 'updated', 'desc'); - return ordered; - }, [downstreamInfoByKey, outOfSyncComponentsByKey]); - if (isIndexDataLoading) { return ; } @@ -259,7 +239,7 @@ const ComponentReviewList = ({ return ( <> - {orderInfo?.map((info) => ( + {downstreamInfo?.map((info) => ( Date: Sat, 3 May 2025 02:48:20 +0930 Subject: [PATCH 02/37] perf: use Library search results to populate container card preview [FC-0083] [TEAK] (#1889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: several library unit page UX bugs (#1868) * fix: rename "Organize" tab to "Manage" * fix: duplicate key warnings * fix: uniform messages while adding to collection * fix: do not allow units be added to a unit (cherry picked from commit 0fdc460c5b59ab54ec55c56bdc2246f328ec7a17) * perf: use Library search results to populate container card preview (#1820) * fix: use Library search results to populate container card preview * feat: show published children when showing only published Unit content * fix: nits (cherry picked from commit 24e469542df017990ad309949c62a0546e99c1e7) --------- Co-authored-by: Rômulo Penido --- .../LibraryAuthoringPage.test.tsx | 2 +- .../add-content/AddContent.test.tsx | 2 +- .../add-content/AddContent.tsx | 9 +++- .../PickLibraryContentModal.test.tsx | 11 +++-- .../add-content/PickLibraryContentModal.tsx | 11 +++-- src/library-authoring/add-content/messages.ts | 11 ++--- .../common/context/SidebarContext.tsx | 2 +- .../components/ContainerCard.test.tsx | 42 +++++++++++++++---- .../components/ContainerCard.tsx | 30 +++++++------ .../containers/ContainerOrganize.tsx | 4 +- src/library-authoring/containers/UnitInfo.tsx | 4 +- src/library-authoring/containers/messages.ts | 20 ++++----- .../ManageCollections.test.tsx | 6 +-- .../manage-collections/ManageCollections.tsx | 5 ++- .../generic/manage-collections/messages.ts | 10 ----- src/library-authoring/generic/messages.ts | 16 +++++++ .../units/LibraryUnitBlocks.tsx | 33 ++++++++------- .../units/LibraryUnitPage.tsx | 2 +- src/search-manager/data/api.ts | 12 ++++-- 19 files changed, 144 insertions(+), 88 deletions(-) create mode 100644 src/library-authoring/generic/messages.ts diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ce07e0c70f..670c3af1a6 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -433,7 +433,7 @@ describe('', () => { const { getByRole, queryByText } = within(sidebar); await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); - expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize'); + expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 8820981c0e..09f01bd174 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -272,7 +272,7 @@ describe('', () => { await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1)); await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl)); - expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.'); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.'); }); it('should stop user from pasting unsupported blocks and show toast', async () => { diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 7610b743f6..8ddbdae354 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -29,6 +29,8 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { PickLibraryContentModal } from './PickLibraryContentModal'; import { blockTypes } from '../../editors/data/constants/app'; +import { ContentType as LibraryContentTypes } from '../routes'; +import genericMessages from '../generic/messages'; import messages from './messages'; import type { BlockTypeMetadata } from '../data/api'; import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils'; @@ -114,6 +116,9 @@ const AddContentView = ({ blockType: 'libraryContent', }; + const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined; + const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined; + return ( <> {(collectionId || unitId) && componentPicker && ( @@ -123,6 +128,8 @@ const AddContentView = ({ )} @@ -301,7 +308,7 @@ const AddContent = () => { const linkComponent = (opaqueKey: string) => { if (collectionId) { addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => { - showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); } if (unitId) { diff --git a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx index a73ce8118e..982b657e8b 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx @@ -92,7 +92,10 @@ describe('', () => { } }); expect(onClose).toHaveBeenCalled(); - expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.'); + const text = context === 'collection' + ? 'Content added to collection.' + : 'Content linked successfully.'; + expect(mockShowToast).toHaveBeenCalledWith(text); }); it(`show error when api call fails (${context})`, async () => { @@ -130,8 +133,10 @@ describe('', () => { } }); expect(onClose).toHaveBeenCalled(); - const name = context === 'collection' ? 'collection' : 'container'; - expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`); + const text = context === 'collection' + ? 'Failed to add content to collection.' + : 'There was an error linking the content to this container.'; + expect(mockShowToast).toHaveBeenCalledWith(text); }); }); }); diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index f71f40d081..4f243f5bf1 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -6,6 +6,8 @@ import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext'; import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks'; +import genericMessages from '../generic/messages'; +import type { ContentType } from '../routes'; import messages from './messages'; interface PickLibraryContentModalFooterProps { @@ -32,12 +34,14 @@ interface PickLibraryContentModalProps { isOpen: boolean; onClose: () => void; extraFilter?: string[]; + visibleTabs?: ContentType[], } export const PickLibraryContentModal: React.FC = ({ isOpen, onClose, extraFilter, + visibleTabs, }) => { const intl = useIntl(); @@ -69,16 +73,16 @@ export const PickLibraryContentModal: React.FC = ( if (collectionId) { updateCollectionItemsMutation.mutateAsync(usageKeys) .then(() => { - showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess)); }) .catch(() => { - showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); } if (unitId) { updateUnitComponentsMutation.mutateAsync(usageKeys) .then(() => { - showToast(intl.formatMessage(messages.successAssociateComponentMessage)); + showToast(intl.formatMessage(messages.successAssociateComponentToContainerMessage)); }) .catch(() => { showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); @@ -109,6 +113,7 @@ export const PickLibraryContentModal: React.FC = ( componentPickerMode="multiple" onChangeComponentSelection={setSelectedComponents} extraFilter={extraFilter} + visibleTabs={visibleTabs} /> ); diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 120b5896fb..cd7e688c5e 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -84,15 +84,10 @@ const messages = defineMessages({ + ' The {detail} text provides more information about the error.' ), }, - successAssociateComponentMessage: { - id: 'course-authoring.library-authoring.associate-collection-content.success.text', + successAssociateComponentToContainerMessage: { + id: 'course-authoring.library-authoring.associate-container-content.success.text', defaultMessage: 'Content linked successfully.', - description: 'Message when linking of content to a collection in library is success', - }, - errorAssociateComponentToCollectionMessage: { - id: 'course-authoring.library-authoring.associate-collection-content.error.text', - defaultMessage: 'There was an error linking the content to this collection.', - description: 'Message when linking of content to a collection in library fails', + description: 'Message when linking of content to a container in library is success', }, errorAssociateComponentToContainerMessage: { id: 'course-authoring.library-authoring.associate-container-content.error.text', diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index 83e545e8eb..f40ccfec1d 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -36,7 +36,7 @@ export const isComponentInfoTab = (tab: string): tab is ComponentInfoTab => ( export const UNIT_INFO_TABS = { Preview: 'preview', - Organize: 'organize', + Manage: 'manage', Usage: 'usage', Settings: 'settings', } as const; diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index a76601e1d0..a8716d7ad1 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -6,7 +6,7 @@ import { fireEvent, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; -import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks'; +import { mockContentLibrary } from '../data/api.mocks'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import ContainerCard from './ContainerCard'; import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api'; @@ -40,7 +40,6 @@ let axiosMock: MockAdapter; let mockShowToast; mockContentLibrary.applyMock(); -mockGetContainerChildren.applyMock(); const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, { extraWrapper: ({ children }) => ( @@ -155,29 +154,54 @@ describe('', () => { it('should render no child blocks in card preview', async () => { render(); - expect(screen.queryByTitle('text block')).not.toBeInTheDocument(); + expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument(); expect(screen.queryByText('+0')).not.toBeInTheDocument(); }); it('should render <=5 child blocks in card preview', async () => { const containerWith5Children = { ...containerHitSample, - usageKey: mockGetContainerChildren.fiveChildren, - }; + content: { + childUsageKeys: Array(5).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + } satisfies ContainerHit; render(); - expect((await screen.findAllByTitle(/text block */)).length).toBe(5); + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(5); expect(screen.queryByText('+0')).not.toBeInTheDocument(); }); it('should render >5 child blocks with +N in card preview', async () => { const containerWith6Children = { ...containerHitSample, - usageKey: mockGetContainerChildren.sixChildren, - }; + content: { + childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + } satisfies ContainerHit; render(); - expect((await screen.findAllByTitle(/text block */)).length).toBe(4); + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(4); expect(screen.queryByText('+2')).toBeInTheDocument(); }); + + it('should render published child blocks when rendering a published card preview', async () => { + const containerWithPublishedChildren = { + ...containerHitSample, + content: { + childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + published: { + content: { + childUsageKeys: Array(2).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`), + }, + }, + } satisfies ContainerHit; + render( + , + true, + ); + + expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(2); + expect(screen.queryByText('+2')).not.toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 45268aa150..a1d3115a61 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -12,12 +12,13 @@ import { MoreVert } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils'; +import { getBlockType } from '../../generic/key-utils'; import { ToastContext } from '../../generic/toast-context'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; -import { useContainerChildren, useRemoveItemsFromCollection } from '../data/apiHooks'; +import { useRemoveItemsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; @@ -107,21 +108,17 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { }; type ContainerCardPreviewProps = { - containerId: string; + childUsageKeys: Array; showMaxChildren?: number; }; -const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => { - const { data, isLoading, isError } = useContainerChildren(containerId); - if (isLoading || isError) { - return null; - } - - const hiddenChildren = data.length - showMaxChildren; +const ContainerCardPreview = ({ childUsageKeys, showMaxChildren = 5 }: ContainerCardPreviewProps) => { + const hiddenChildren = childUsageKeys.length - showMaxChildren; return ( { - data.slice(0, showMaxChildren).map(({ id, blockType, displayName }, idx) => { + childUsageKeys.slice(0, showMaxChildren).map((usageKey, idx) => { + const blockType = getBlockType(usageKey); let blockPreview: ReactNode; let classNames; @@ -133,7 +130,7 @@ const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCar ); } else { @@ -147,7 +144,9 @@ const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCar } return (
{blockPreview} @@ -176,6 +175,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { published, publishStatus, usageKey: unitId, + content, } = hit; const numChildrenCount = showOnlyPublished ? ( @@ -186,6 +186,10 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { showOnlyPublished ? formatted.published?.displayName : formatted.displayName ) ?? ''; + const childUsageKeys: Array = ( + showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys + ) ?? []; + const { navigateTo } = useLibraryRoutes(); const openContainer = useCallback(() => { @@ -200,7 +204,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { } + preview={} tags={tags} numChildren={numChildrenCount} actions={( diff --git a/src/library-authoring/containers/ContainerOrganize.tsx b/src/library-authoring/containers/ContainerOrganize.tsx index e6585785e2..5c336abc04 100644 --- a/src/library-authoring/containers/ContainerOrganize.tsx +++ b/src/library-authoring/containers/ContainerOrganize.tsx @@ -85,7 +85,7 @@ const ContainerOrganize = () => { > - {intl.formatMessage(messages.organizeTabTagsTitle, { count: tagsCount })} + {intl.formatMessage(messages.manageTabTagsTitle, { count: tagsCount })} @@ -113,7 +113,7 @@ const ContainerOrganize = () => { > - {intl.formatMessage(messages.organizeTabCollectionsTitle, { count: collectionsCount })} + {intl.formatMessage(messages.manageTabCollectionsTitle, { count: collectionsCount })} diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index 164962fcc6..df70791e14 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -120,7 +120,7 @@ const UnitInfo = () => { useEffect(() => { // Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo if (jumpToCollections) { - setSidebarTab(UNIT_INFO_TABS.Organize); + setSidebarTab(UNIT_INFO_TABS.Manage); } }, [jumpToCollections, setSidebarTab]); @@ -166,7 +166,7 @@ const UnitInfo = () => { onSelect={setSidebarTab} > {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} - {renderTab(UNIT_INFO_TABS.Organize, , intl.formatMessage(messages.organizeTabTitle))} + {renderTab(UNIT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} {renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))} diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 9ebae29e1b..fe8e0139ed 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -11,20 +11,20 @@ const messages = defineMessages({ defaultMessage: 'Preview', description: 'Title for preview tab', }, - organizeTabTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.title', - defaultMessage: 'Organize', - description: 'Title for organize tab', + manageTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.title', + defaultMessage: 'Manage', + description: 'Title for manage tab', }, - organizeTabTagsTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.tags.title', + manageTabTagsTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.tags.title', defaultMessage: 'Tags ({count})', - description: 'Title for tags section in organize tab', + description: 'Title for tags section in manage tab', }, - organizeTabCollectionsTitle: { - id: 'course-authoring.library-authoring.container-sidebar.organize-tab.collections.title', + manageTabCollectionsTitle: { + id: 'course-authoring.library-authoring.container-sidebar.manage-tab.collections.title', defaultMessage: 'Collections ({count})', - description: 'Title for collections section in organize tab', + description: 'Title for collections section in manage tab', }, publishContainerButton: { id: 'course-authoring.library-authoring.container-sidebar.publish-button', diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx index b73dd0d837..7f7b79d436 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.test.tsx @@ -77,7 +77,7 @@ describe('', () => { await waitFor(() => { expect(axiosMock.history.patch.length).toEqual(1); }); - expect(mockShowToast).toHaveBeenCalledWith('Item collections updated'); + expect(mockShowToast).toHaveBeenCalledWith('Content added to collection.'); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-first-collection', 'my-second-collection'], }); @@ -103,7 +103,7 @@ describe('', () => { await waitFor(() => { expect(axiosMock.history.patch.length).toEqual(1); }); - expect(mockShowToast).toHaveBeenCalledWith('Item collections updated'); + expect(mockShowToast).toHaveBeenCalledWith('Content added to collection.'); expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-first-collection', 'my-second-collection'], }); @@ -133,7 +133,7 @@ describe('', () => { expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ collection_keys: ['my-second-collection'], }); - expect(mockShowToast).toHaveBeenCalledWith('Failed to update item collections'); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.'); expect(screen.queryByRole('search')).not.toBeInTheDocument(); }); diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.tsx index 41bc36ce7c..96591b3274 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.tsx @@ -16,6 +16,7 @@ import { ToastContext } from '../../../generic/toast-context'; import { CollectionMetadata } from '../../data/api'; import { useLibraryContext } from '../../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../../common/context/SidebarContext'; +import genericMessages from '../messages'; import messages from './messages'; interface ManageCollectionsProps { @@ -50,9 +51,9 @@ const CollectionsSelectableBox = ({ const handleConfirmation = () => { setBtnState('pending'); updateCollectionsMutation.mutateAsync(selectedCollections).then(() => { - showToast(intl.formatMessage(messages.manageCollectionsToComponentSuccess)); + showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess)); }).catch(() => { - showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed)); + showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }).finally(() => { setBtnState('default'); onClose(); diff --git a/src/library-authoring/generic/manage-collections/messages.ts b/src/library-authoring/generic/manage-collections/messages.ts index c9b998be47..1afefa4967 100644 --- a/src/library-authoring/generic/manage-collections/messages.ts +++ b/src/library-authoring/generic/manage-collections/messages.ts @@ -21,16 +21,6 @@ const messages = defineMessages({ defaultMessage: 'Collection selection', description: 'Aria label text for collection selection box', }, - manageCollectionsToComponentSuccess: { - id: 'course-authoring.library-authoring.manage-collections.add-success', - defaultMessage: 'Item collections updated', - description: 'Message to display on updating item collections', - }, - manageCollectionsToComponentFailed: { - id: 'course-authoring.library-authoring.manage-collections.add-failed', - defaultMessage: 'Failed to update item collections', - description: 'Message to display on failure of updating item collections', - }, manageCollectionsToComponentConfirmBtn: { id: 'course-authoring.library-authoring.manage-collections.add-confirm-btn', defaultMessage: 'Confirm', diff --git a/src/library-authoring/generic/messages.ts b/src/library-authoring/generic/messages.ts new file mode 100644 index 0000000000..e1aec050f0 --- /dev/null +++ b/src/library-authoring/generic/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + manageCollectionsSuccess: { + id: 'course-authoring.library-authoring.manage-collections.success', + defaultMessage: 'Content added to collection.', + description: 'Message to display on updating item collections', + }, + manageCollectionsFailed: { + id: 'course-authoring.library-authoring.manage-collections.failed', + defaultMessage: 'Failed to add content to collection.', + description: 'Message to display on failure of updating item collections', + }, +}); + +export default messages; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 1ef20fa39a..43e985eb8c 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -28,7 +28,7 @@ import { useUpdateXBlockFields, } from '../data/apiHooks'; import { LibraryBlock } from '../LibraryBlock'; -import { useLibraryRoutes } from '../routes'; +import { useLibraryRoutes, ContentType } from '../routes'; import messages from './messages'; import { useSidebarContext } from '../common/context/SidebarContext'; import { ToastContext } from '../../generic/toast-context'; @@ -200,8 +200,10 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { ); }; - const renderedBlocks = orderedBlocks?.map((block) => ( - + const renderedBlocks = orderedBlocks?.map((block, idx) => ( + // A container can have multiple instances of the same block + // eslint-disable-next-line react/no-array-index-key + { disabled={preview} > {hidePreviewFor !== block.id && ( -
- -
+
+ +
)}
@@ -245,7 +247,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { > {renderedBlocks} - { !preview && ( + {!preview && (
diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index e362cdb900..a84bed2a9f 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -137,7 +137,7 @@ export const LibraryUnitPage = () => { setDefaultTab({ collection: COLLECTION_INFO_TABS.Details, component: COMPONENT_INFO_TABS.Manage, - unit: UNIT_INFO_TABS.Organize, + unit: UNIT_INFO_TABS.Manage, }); setHiddenTabs([COMPONENT_INFO_TABS.Preview, UNIT_INFO_TABS.Preview]); return () => { diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 549054b3fe..e1a6aeaa33 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -50,6 +50,7 @@ export const getContentSearchConfig = async (): Promise<{ url: string, indexName export interface ContentDetails { htmlContent?: string; capaContent?: string; + childUsageKeys?: Array; [k: string]: any; } @@ -151,9 +152,10 @@ export interface ContentHit extends BaseContentHit { * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py */ export interface ContentPublishedData { - description?: string, - displayName?: string, - numChildren?: number, + description?: string; + displayName?: string; + numChildren?: number; + content?: ContentDetails; } /** @@ -171,6 +173,9 @@ export interface CollectionHit extends BaseContentHit { * Information about a single container returned in the search results * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py */ +interface ContainerHitContent { + childUsageKeys?: string[], +} export interface ContainerHit extends BaseContentHit { type: 'library_container'; blockType: 'unit'; // This should be expanded to include other container types @@ -178,6 +183,7 @@ export interface ContainerHit extends BaseContentHit { published?: ContentPublishedData; publishStatus: PublishStatus; formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, }; + content?: ContainerHitContent; } export type HitType = ContentHit | CollectionHit | ContainerHit; From 8ffafc094f8a15a7ffe55e935db8d8e024fafcaa Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Wed, 7 May 2025 20:40:34 +0200 Subject: [PATCH 03/37] fix: manage access modal on duplicated xblock (#1874) --- src/course-unit/CourseUnit.test.jsx | 15 +++++++++++++++ src/course-unit/data/thunk.js | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 7ffdd93d72..419bd46987 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -468,6 +468,10 @@ describe('', () => { id: courseVerticalChildrenMock.children[0].block_id, }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + axiosMock .onPost(postXBlockBaseApiUrl({ parent_locator: blockId, @@ -2174,6 +2178,17 @@ describe('', () => { ? { ...child, block_type: 'html' } : child)); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + + axiosMock + .onPost(postXBlockBaseApiUrl({ + parent_locator: blockId, + duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id, + })) + .replyOnce(200, { locator: '1234567890' }); + axiosMock .onGet(getCourseVerticalChildrenApiUrl(blockId)) .reply(200, updatedCourseVerticalChildrenMock); diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index a0c1dc54ec..ee2c4da655 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -262,6 +262,8 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) { callback(courseKey, locator); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); + const courseVerticalChildrenData = await getCourseVerticalChildren(itemId); + dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { From d5e36cf2b8f100e86869c07fcdf488d7bf181a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 7 May 2025 19:39:55 -0300 Subject: [PATCH 04/37] fix: unit pages ux bugs [FC-0083] (#1884) (#1916) This PR fixes some UX bugs related to the unit pages: * Sort for "recently modified" on unit tab does not update after adding new components to units * Change component delete warning message It's a backport of https://github.com/openedx/frontend-app-authoring/pull/1884 --- .../{DeleteModal.jsx => DeleteModal.tsx} | 43 +++++++------------ .../delete-modal/{messages.js => messages.ts} | 0 .../LibraryAuthoringPage.tsx | 2 +- .../components/ComponentDeleter.tsx | 30 ++++++++----- .../components/ContainerDeleter.tsx | 4 +- src/library-authoring/components/messages.ts | 12 +++++- src/library-authoring/data/apiHooks.ts | 17 ++++++-- 7 files changed, 61 insertions(+), 47 deletions(-) rename src/generic/delete-modal/{DeleteModal.jsx => DeleteModal.tsx} (71%) rename src/generic/delete-modal/{messages.js => messages.ts} (100%) diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.tsx similarity index 71% rename from src/generic/delete-modal/DeleteModal.jsx rename to src/generic/delete-modal/DeleteModal.tsx index 4159d9d3f2..cf81b96363 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { ActionRow, Button, @@ -9,17 +8,29 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import LoadingButton from '../loading-button'; +interface DeleteModalProps { + isOpen: boolean; + close: () => void; + category?: string; + onDeleteSubmit: () => void | Promise; + title?: string; + description?: React.ReactNode | React.ReactNode[]; + variant?: string; + btnLabel?: string; + icon?: React.ElementType; +} + const DeleteModal = ({ - category, + category = '', isOpen, close, onDeleteSubmit, title, description, - variant, + variant = 'default', btnLabel, icon, -}) => { +}: DeleteModalProps) => { const intl = useIntl(); const modalTitle = title || intl.formatMessage(messages.title, { category }); @@ -62,28 +73,4 @@ const DeleteModal = ({ ); }; -DeleteModal.defaultProps = { - category: '', - title: '', - description: '', - variant: 'default', - btnLabel: '', - icon: null, -}; - -DeleteModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - close: PropTypes.func.isRequired, - category: PropTypes.string, - onDeleteSubmit: PropTypes.func.isRequired, - title: PropTypes.string, - description: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.string, - ]), - variant: PropTypes.string, - btnLabel: PropTypes.string, - icon: PropTypes.elementType, -}; - export default DeleteModal; diff --git a/src/generic/delete-modal/messages.js b/src/generic/delete-modal/messages.ts similarity index 100% rename from src/generic/delete-modal/messages.js rename to src/generic/delete-modal/messages.ts diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 6ffc4182de..95172f8d01 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -256,7 +256,7 @@ const LibraryAuthoringPage = ({ [ContentType.units]: intl.formatMessage(messages.unitsTab), }; const visibleTabsToRender = visibleTabs.map((contentType) => ( - + )); return ( diff --git a/src/library-authoring/components/ComponentDeleter.tsx b/src/library-authoring/components/ComponentDeleter.tsx index 2ea3a99e8e..c48dedec1b 100644 --- a/src/library-authoring/components/ComponentDeleter.tsx +++ b/src/library-authoring/components/ComponentDeleter.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Warning } from '@openedx/paragon/icons'; +import { Icon } from '@openedx/paragon'; +import { CalendarViewDay, School, Warning } from '@openedx/paragon/icons'; import { useSidebarContext } from '../common/context/SidebarContext'; import { useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock } from '../data/apiHooks'; @@ -66,6 +67,22 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => { return null; } + const deleteText = intl.formatMessage(messages.deleteComponentConfirm, { + componentName: , + message: ( + <> +
+ + {intl.formatMessage(messages.deleteComponentConfirmMsg1)} +
+
+ + {intl.formatMessage(messages.deleteComponentConfirmMsg2)} +
+ + ), + }); + return ( { variant="warning" title={intl.formatMessage(messages.deleteComponentWarningTitle)} icon={Warning} - description={( - - ), - }} - /> -)} + description={deleteText} onDeleteSubmit={doDelete} /> ); diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index 9b7d5db05a..a4a1affc15 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Warning, School, Widgets } from '@openedx/paragon/icons'; @@ -47,7 +47,7 @@ const ContainerDeleter = ({
), - }) as ReactNode as string; + }); const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess); const deleteError = intl.formatMessage(messages.deleteUnitFailed); const undoDeleteError = messages.undoDeleteUnitToastFailed; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 2507930770..8248b4f43c 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -68,9 +68,19 @@ const messages = defineMessages({ }, deleteComponentConfirm: { id: 'course-authoring.library-authoring.component.delete-confirmation-text', - defaultMessage: 'Delete {componentName}? If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.', + defaultMessage: 'Delete {componentName}? {message}', description: 'Confirmation text to display before deleting a component', }, + deleteComponentConfirmMsg1: { + id: 'course-authoring.library-authoring.component.delete-confirmation-msg-1', + defaultMessage: 'If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.', + description: 'First part of confirmation message to display before deleting a component', + }, + deleteComponentConfirmMsg2: { + id: 'course-authoring.library-authoring.component.delete-confirmation-msg-2', + defaultMessage: 'If this component has been used in any units, it will also be deleted from those units.', + description: 'Second part of confirmation message to display before deleting a component', + }, deleteComponentCancelButton: { id: 'course-authoring.library-authoring.component.cancel-delete-button', defaultMessage: 'Cancel', diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index e9a5202b14..b9a825b34f 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -642,13 +642,22 @@ export const useAddComponentsToContainer = (containerId?: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (componentIds: string[]) => { - if (containerId !== undefined) { - return api.addComponentsToContainer(containerId, componentIds); + // istanbul ignore if: this should never happen + if (!containerId) { + return undefined; } - return undefined; + return api.addComponentsToContainer(containerId, componentIds); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!) }); + // istanbul ignore if: this should never happen + if (!containerId) { + return; + } + // NOTE: We invalidate the library query here because we need to update the library's + // container list. + const libraryId = getLibraryId(containerId); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); }, }); }; From 79f865b328aeca14623d54bc59a1c37648b47d6f Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 8 May 2025 15:15:44 +0000 Subject: [PATCH 05/37] fix: UX issues in unit page (#1913) (#1923) Fixes the following issues: * Selection behavior * Component selection is by header click only * Newly created blocks within a unit should be selected on creation/save, appear selected, and have their sidebar open * Some long text components seem to display at the default height rather than a longer height * Within the full-page unit view, the "add to collection" overflow menu item on components does not seem to work/only opens the sidebar. * Draft status indicator text is not vertically centered with icon * When reordering, dragging a short component past a long component often causes a strange stutter effect. * When dragging to reorder a component, moving quickly or scrolling often causes the drag handle to be lost / causes the block to jump somewhere else * Reordering may not consistently support a keyboard-accessible option to change order, like in course authoring * Tag button on component header opens the old tag side pane (cherry picked from commit 8c3fab37922fd6ff03a0fa32917b8ac66dd199d1) --- codecov.yml | 1 + .../ContentTagsDrawer.test.jsx | 26 ++ src/content-tags-drawer/ContentTagsDrawer.tsx | 12 +- src/content-tags-drawer/data/apiHooks.jsx | 8 +- .../data/apiHooks.test.jsx | 2 +- src/course-outline/CourseOutline.test.jsx | 2 +- src/generic/DraggableList/DraggableList.jsx | 13 +- src/generic/DraggableList/SortableItem.jsx | 3 +- .../DraggableList/verticalSortableList.ts | 80 +++++ src/generic/hooks/tests/hooks.test.tsx | 3 +- src/generic/hooks/useIframeBehavior.tsx | 3 +- .../LibraryAuthoringPage.test.tsx | 22 +- .../LibraryBlock/LibraryBlock.tsx | 10 + .../common/context/SidebarContext.tsx | 3 +- .../component-info/ComponentInfo.tsx | 19 +- .../ComponentManagement.test.tsx | 47 ++- .../component-info/ComponentManagement.tsx | 8 +- .../components/ComponentMenu.tsx | 27 +- .../components/ContainerCard.tsx | 13 +- .../containers/ContainerOrganize.tsx | 2 +- src/library-authoring/containers/UnitInfo.tsx | 21 +- src/library-authoring/data/api.ts | 3 + src/library-authoring/data/apiHooks.ts | 17 + .../manage-collections/ManageCollections.tsx | 4 +- .../library-sidebar/LibrarySidebar.tsx | 35 +- src/library-authoring/routes.test.tsx | 8 + src/library-authoring/routes.ts | 24 +- .../units/LibraryUnitBlocks.tsx | 309 ++++++++++-------- .../units/LibraryUnitPage.test.tsx | 44 ++- .../units/LibraryUnitPage.tsx | 21 +- src/library-authoring/units/index.scss | 5 + src/utils.js | 21 +- 32 files changed, 598 insertions(+), 218 deletions(-) create mode 100644 src/generic/DraggableList/verticalSortableList.ts diff --git a/codecov.yml b/codecov.yml index 64b8f80010..3202455e6d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,4 +10,5 @@ coverage: threshold: 0% ignore: - "src/grading-settings/grading-scale/react-ranger.js" + - "src/generic/DraggableList/verticalSortableList.ts" - "src/index.js" diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 000badea95..5035b2fe43 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -21,6 +21,7 @@ const path = '/content/:contentId?/*'; const mockOnClose = jest.fn(); const mockSetBlockingSheet = jest.fn(); const mockNavigate = jest.fn(); +const mockSidebarAction = jest.fn(); mockContentTaxonomyTagsData.applyMock(); mockTaxonomyListData.applyMock(); mockTaxonomyTagsData.applyMock(); @@ -40,6 +41,11 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); +jest.mock('../library-authoring/common/context/SidebarContext', () => ({ + ...jest.requireActual('../library-authoring/common/context/SidebarContext'), + useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }), +})); + const renderDrawer = (contentId, drawerParams = {}) => ( render( @@ -184,6 +190,26 @@ describe('', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); + it('should change to edit mode sidebar action is set to JumpToManageTags', async () => { + mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags'); + renderDrawer(stagedTagsId, { variant: 'component' }); + expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + + // Show delete tag buttons + expect(screen.getAllByRole('button', { + name: /delete/i, + }).length).toBe(2); + + // Show add a tag select + expect(screen.getByText(/add a tag/i)).toBeInTheDocument(); + + // Show cancel button + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + + // Show save button + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + it('should change to read mode when click on `Cancel` on drawer variant', async () => { renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); diff --git a/src/content-tags-drawer/ContentTagsDrawer.tsx b/src/content-tags-drawer/ContentTagsDrawer.tsx index 9253205b36..7b278dcce0 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.tsx +++ b/src/content-tags-drawer/ContentTagsDrawer.tsx @@ -14,6 +14,7 @@ import ContentTagsCollapsible from './ContentTagsCollapsible'; import Loading from '../generic/Loading'; import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper'; import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; +import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext'; interface TaxonomyListProps { contentId: string; @@ -244,6 +245,7 @@ const ContentTagsDrawer = ({ if (contentId === undefined) { throw new Error('Error: contentId cannot be null.'); } + const { sidebarAction } = useSidebarContext(); const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer'); const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); @@ -260,6 +262,7 @@ const ContentTagsDrawer = ({ closeToast, setCollapsibleToInitalState, otherTaxonomies, + toEditMode, } = context; let onCloseDrawer: () => void; @@ -302,8 +305,13 @@ const ContentTagsDrawer = ({ // First call of the initial collapsible states React.useEffect(() => { - setCollapsibleToInitalState(); - }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); + // Open tag edit mode when sidebarAction is JumpToManageTags + if (sidebarAction === SidebarActions.JumpToManageTags) { + toEditMode(); + } else { + setCollapsibleToInitalState(); + } + }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]); const renderFooter = () => { if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index c32be8e3f7..01ef3a3a7b 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -7,6 +7,7 @@ import { useMutation, useQueryClient, } from '@tanstack/react-query'; +import { useParams } from 'react-router'; import { getTaxonomyTagsData, getContentTaxonomyTagsData, @@ -14,7 +15,7 @@ import { updateContentTaxonomyTags, getContentTaxonomyTagsCount, } from './api'; -import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; +import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; import { getLibraryId } from '../../generic/key-utils'; /** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */ @@ -129,6 +130,7 @@ export const useContentData = (contentId, enabled) => ( export const useContentTaxonomyTagsUpdater = (contentId) => { const queryClient = useQueryClient(); const unitIframe = window.frames['xblock-iframe']; + const { unitId } = useParams(); return useMutation({ /** @@ -158,6 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => { queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId)); // Invalidate content search to update tags count queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again. + if (unitId) { + queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId)); + } } }, onSuccess: /* istanbul ignore next */ () => { diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 2ce95d8465..ebfaf9e779 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -157,7 +157,7 @@ describe('useContentTaxonomyTagsUpdater', () => { const contentId = 'testerContent'; const taxonomyId = 123; - const mutation = useContentTaxonomyTagsUpdater(contentId); + const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current; const tagsData = [{ taxonomy: taxonomyId, tags: ['tag1', 'tag2'], diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 055f56cdb5..b0959c8218 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -2230,7 +2230,7 @@ describe('', () => { .reply(200, courseSectionMock); let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await userEvent.click(expandBtn); + userEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); diff --git a/src/generic/DraggableList/DraggableList.jsx b/src/generic/DraggableList/DraggableList.jsx index 1515f29a06..ef86c45f03 100644 --- a/src/generic/DraggableList/DraggableList.jsx +++ b/src/generic/DraggableList/DraggableList.jsx @@ -1,10 +1,9 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import PropTypes from 'prop-types'; import { createPortal } from 'react-dom'; import { DndContext, - closestCenter, KeyboardSensor, PointerSensor, useSensor, @@ -18,6 +17,7 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { verticalSortableListCollisionDetection } from './verticalSortableList'; const DraggableList = ({ itemList, @@ -56,13 +56,20 @@ const DraggableList = ({ setActiveId?.(event.active.id); }, [setActiveId]); + const handleDragCancel = useCallback(() => { + setActiveId?.(null); + }, [setActiveId]); + return ( {actions} diff --git a/src/generic/DraggableList/verticalSortableList.ts b/src/generic/DraggableList/verticalSortableList.ts new file mode 100644 index 0000000000..0d3409e32a --- /dev/null +++ b/src/generic/DraggableList/verticalSortableList.ts @@ -0,0 +1,80 @@ +/* istanbul ignore file */ +/** +This sorting strategy was copied over from https://github.com/clauderic/dnd-kit/pull/805 +to resolve issues with variable sized draggables. +*/ +import { CollisionDetection, DroppableContainer } from '@dnd-kit/core'; +import { sortBy } from 'lodash'; + +const collision = (dropppableContainer?: DroppableContainer) => ({ + id: dropppableContainer?.id ?? '', + value: dropppableContainer, +}); + +// Look for the first (/ furthest up / highest) droppable container that is at least +// 50% covered by the top edge of the dragging container. +const highestDroppableContainerMajorityCovered: CollisionDetection = ({ + droppableContainers, + collisionRect, +}) => { + const ascendingDroppabaleContainers = sortBy( + droppableContainers, + (c) => c?.rect.current?.top, + ); + + for (const droppableContainer of ascendingDroppabaleContainers) { + const { + rect: { current: droppableRect }, + } = droppableContainer; + + if (droppableRect) { + const coveredPercentage = (droppableRect.top + droppableRect.height - collisionRect.top) + / droppableRect.height; + + if (coveredPercentage > 0.5) { + return [collision(droppableContainer)]; + } + } + } + + // if we haven't found anything then we are off the top, so return the first item + return [collision(ascendingDroppabaleContainers[0])]; +}; + +// Look for the last (/ furthest down / lowest) droppable container that is at least +// 50% covered by the bottom edge of the dragging container. +const lowestDroppableContainerMajorityCovered: CollisionDetection = ({ + droppableContainers, + collisionRect, +}) => { + const descendingDroppabaleContainers = sortBy( + droppableContainers, + (c) => c?.rect.current?.top, + ).reverse(); + + for (const droppableContainer of descendingDroppabaleContainers) { + const { + rect: { current: droppableRect }, + } = droppableContainer; + + if (droppableRect) { + const coveredPercentage = (collisionRect.bottom - droppableRect.top) / droppableRect.height; + + if (coveredPercentage > 0.5) { + return [collision(droppableContainer)]; + } + } + } + + // if we haven't found anything then we are off the bottom, so return the last item + return [collision(descendingDroppabaleContainers[0])]; +}; + +export const verticalSortableListCollisionDetection: CollisionDetection = ( + args, +) => { + if (args.collisionRect.top < (args.active.rect.current?.initial?.top ?? 0)) { + return highestDroppableContainerMajorityCovered(args); + } + return lowestDroppableContainerMajorityCovered(args); +}; diff --git a/src/generic/hooks/tests/hooks.test.tsx b/src/generic/hooks/tests/hooks.test.tsx index bf15d83ba2..d2e83f06fc 100644 --- a/src/generic/hooks/tests/hooks.test.tsx +++ b/src/generic/hooks/tests/hooks.test.tsx @@ -96,7 +96,8 @@ describe('useIframeBehavior', () => { window.dispatchEvent(new MessageEvent('message', message)); }); - expect(setIframeHeight).toHaveBeenCalledWith(500); + // +10 padding + expect(setIframeHeight).toHaveBeenCalledWith(510); expect(setHasLoaded).toHaveBeenCalledWith(true); }); diff --git a/src/generic/hooks/useIframeBehavior.tsx b/src/generic/hooks/useIframeBehavior.tsx index 2c327a23dd..1e60a6f943 100644 --- a/src/generic/hooks/useIframeBehavior.tsx +++ b/src/generic/hooks/useIframeBehavior.tsx @@ -46,7 +46,8 @@ export const useIframeBehavior = ({ switch (type) { case iframeMessageTypes.resize: - setIframeHeight(payload.height); + // Adding 10px as padding + setIframeHeight(payload.height + 10); if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { setHasLoaded(true); } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 670c3af1a6..cded3d3340 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -55,6 +55,10 @@ const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + beforeEach(async () => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; @@ -78,6 +82,10 @@ describe('', () => { }); }); + afterAll(() => { + jest.useRealTimers(); + }); + const renderLibraryPage = async () => { render(, { path, params: { libraryId: mockContentLibrary.libraryId } }); @@ -392,7 +400,7 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => { + it('should open component sidebar, showing manage tab on clicking add to collection menu item - component', async () => { const mockResult0 = { ...mockResult }.results[0].hits[0]; const displayName = 'Introduction to Testing'; expect(mockResult0.display_name).toStrictEqual(displayName); @@ -407,9 +415,10 @@ describe('', () => { const sidebar = screen.getByTestId('library-sidebar'); - const { getByRole, queryByText } = within(sidebar); + const { getByRole, findByText } = within(sidebar); - await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); + expect(await findByText(displayName)).toBeInTheDocument(); + jest.advanceTimersByTime(300); expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); @@ -417,7 +426,7 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => { + it('should open component sidebar, showing manage tab on clicking add to collection menu item - unit', async () => { const displayName = 'Test Unit'; await renderLibraryPage(); @@ -430,9 +439,10 @@ describe('', () => { const sidebar = screen.getByTestId('library-sidebar'); - const { getByRole, queryByText } = within(sidebar); + const { getByRole, findByText } = within(sidebar); - await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); + expect(await findByText(displayName)).toBeInTheDocument(); + jest.advanceTimersByTime(300); expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); const closeButton = getByRole('button', { name: /close/i }); fireEvent.click(closeButton); diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index c87091acdb..52e0794e03 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -16,6 +17,7 @@ interface LibraryBlockProps { view?: string; scrolling?: string; minHeight?: string; + scrollIntoView?: boolean; } /** * React component that displays an XBlock in a sandboxed IFrame. @@ -33,6 +35,7 @@ export const LibraryBlock = ({ view, minHeight, scrolling = 'no', + scrollIntoView = false, }: LibraryBlockProps) => { const { iframeRef, setIframeRef } = useIframe(); const xblockView = view ?? 'student_view'; @@ -49,6 +52,13 @@ export const LibraryBlock = ({ onBlockNotification, }); + useEffect(() => { + /* istanbul ignore next */ + if (scrollIntoView) { + iframeRef?.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [scrollIntoView]); + useIframeContent(iframeRef, setIframeRef); return ( diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index f40ccfec1d..0657842876 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -63,7 +63,8 @@ export interface SidebarComponentInfo { } export enum SidebarActions { - JumpToAddCollections = 'jump-to-add-collections', + JumpToManageCollections = 'jump-to-manage-collections', + JumpToManageTags = 'jump-to-manage-tags', ManageTeam = 'manage-team', None = '', } diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 1b542c6956..635e54cc7e 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, @@ -17,7 +17,6 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { type ComponentInfoTab, COMPONENT_INFO_TABS, - SidebarActions, isComponentInfoTab, useSidebarContext, } from '../common/context/SidebarContext'; @@ -107,9 +106,9 @@ const ComponentInfo = () => { sidebarTab, setSidebarTab, sidebarComponentInfo, - sidebarAction, defaultTab, hiddenTabs, + resetSidebarAction, } = useSidebarContext(); const [ isPublishConfirmationOpen, @@ -117,20 +116,16 @@ const ComponentInfo = () => { closePublishConfirmation, ] = useToggle(false); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; - const tab: ComponentInfoTab = ( isComponentInfoTab(sidebarTab) ? sidebarTab : defaultTab.component ); - useEffect(() => { - // Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo - if (jumpToCollections) { - setSidebarTab(COMPONENT_INFO_TABS.Manage); - } - }, [jumpToCollections, setSidebarTab]); + const handleTabChange = (newTab: ComponentInfoTab) => { + resetSidebarAction(); + setSidebarTab(newTab); + }; const usageKey = sidebarComponentInfo?.id; // istanbul ignore if: this should never happen @@ -198,7 +193,7 @@ const ComponentInfo = () => { className="my-3 d-flex justify-content-around" defaultActiveKey={defaultTab.component} activeKey={tab} - onSelect={setSidebarTab} + onSelect={handleTabChange} > {renderTab(COMPONENT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} {renderTab(COMPONENT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx index 9e070cde96..0e44912652 100644 --- a/src/library-authoring/component-info/ComponentManagement.test.tsx +++ b/src/library-authoring/component-info/ComponentManagement.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from '../../testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; -import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; +import { SidebarActions, SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks'; import ComponentManagement from './ComponentManagement'; @@ -19,6 +19,16 @@ jest.mock('../../content-tags-drawer', () => ({ ), })); +const mockSearchParam = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts + useSearchParams: () => [ + { getAll: (paramName: string) => mockSearchParam(paramName) }, + () => {}, + ], +})); + mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockContentTaxonomyTagsData.applyMock(); @@ -55,6 +65,11 @@ const render = (usageKey: string, libraryId?: string) => baseRender(', () => { beforeEach(() => { initializeMocks(); + mockSearchParam.mockResolvedValue([undefined, () => {}]); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should render draft status', async () => { @@ -119,4 +134,34 @@ describe('', () => { render(mockLibraryBlockMetadata.usageKeyWithCollections); expect(await screen.findByText('Collections (1)')).toBeInTheDocument(); }); + + it('should open collection section when sidebarAction = JumpToManageCollections', async () => { + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + mockSearchParam.mockReturnValue([SidebarActions.JumpToManageCollections]); + render(mockLibraryBlockMetadata.usageKeyWithCollections); + expect(await screen.findByText('Collections (1)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument(); + const tagsSection = await screen.findByRole('button', { name: 'Tags (0)' }); + expect(tagsSection).toHaveAttribute('aria-expanded', 'false'); + const collectionsSection = await screen.findByRole('button', { name: 'Collections (1)' }); + expect(collectionsSection).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should open tags section when sidebarAction = JumpToManageTags', async () => { + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + mockSearchParam.mockReturnValue([SidebarActions.JumpToManageTags]); + render(mockLibraryBlockMetadata.usageKeyForTags); + expect(await screen.findByText('Collections (0)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument(); + const tagsSection = await screen.findByRole('button', { name: 'Tags (6)' }); + expect(tagsSection).toHaveAttribute('aria-expanded', 'true'); + const collectionsSection = await screen.findByRole('button', { name: 'Collections (0)' }); + expect(collectionsSection).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index c0eccdc5e4..5c3b6a664b 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -18,7 +18,8 @@ const ComponentManagement = () => { const intl = useIntl(); const { readOnly, isLoadingLibraryData } = useLibraryContext(); const { sidebarComponentInfo, sidebarAction, resetSidebarAction } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; + const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags; const [tagsCollapseIsOpen, setTagsCollapseOpen] = React.useState(!jumpToCollections); const [collectionsCollapseIsOpen, setCollectionsCollapseOpen] = React.useState(true); @@ -26,8 +27,11 @@ const ComponentManagement = () => { if (jumpToCollections) { setTagsCollapseOpen(false); setCollectionsCollapseOpen(true); + } else if (jumpToTags) { + setTagsCollapseOpen(true); + setCollectionsCollapseOpen(false); } - }, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]); + }, [jumpToCollections, jumpToTags]); useEffect(() => { // This is required to redo actions. diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index 589901326b..e3d0afe64f 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -20,6 +20,8 @@ import { import { canEditComponent } from './ComponentEditorModal'; import ComponentDeleter from './ComponentDeleter'; import messages from './messages'; +import { useLibraryRoutes } from '../routes'; +import { useRunOnNextRender } from '../../utils'; export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { const intl = useIntl(); @@ -36,6 +38,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { closeLibrarySidebar, setSidebarAction, } = useSidebarContext(); + const { navigateTo } = useLibraryRoutes(); const canEdit = usageKey && canEditComponent(usageKey); const { showToast } = useContext(ToastContext); @@ -87,10 +90,22 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { }); }; + 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. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections), 250); + }); + const showManageCollections = useCallback(() => { - setSidebarAction(SidebarActions.JumpToAddCollections); + navigateTo({ componentId: usageKey }); openComponentInfoSidebar(usageKey); - }, [setSidebarAction, openComponentInfoSidebar, usageKey]); + scheduleJumpToCollection(); + }, [ + scheduleJumpToCollection, + openComponentInfoSidebar, + usageKey, + navigateTo, + ]); return ( @@ -123,11 +138,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { )} - {!unitId && ( - - - - )} + + + diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index a1d3115a61..97e30e3823 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -24,6 +24,7 @@ import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; import messages from './messages'; import ContainerDeleter from './ContainerDeleter'; +import { useRunOnNextRender } from '../../utils'; type ContainerMenuProps = { hit: ContainerHit, @@ -45,6 +46,7 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { } = useSidebarContext(); const { showToast } = useContext(ToastContext); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); + const { navigateTo } = useLibraryRoutes(); const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); @@ -60,10 +62,17 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { }); }; + 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. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections)); + }); + const showManageCollections = useCallback(() => { - setSidebarAction(SidebarActions.JumpToAddCollections); + navigateTo({ unitId: containerId }); openUnitInfoSidebar(containerId); - }, [setSidebarAction, openUnitInfoSidebar, containerId]); + scheduleJumpToCollection(); + }, [scheduleJumpToCollection, navigateTo, openUnitInfoSidebar, containerId]); return ( <> diff --git a/src/library-authoring/containers/ContainerOrganize.tsx b/src/library-authoring/containers/ContainerOrganize.tsx index 5c336abc04..6419bfd430 100644 --- a/src/library-authoring/containers/ContainerOrganize.tsx +++ b/src/library-authoring/containers/ContainerOrganize.tsx @@ -28,7 +28,7 @@ const ContainerOrganize = () => { const { readOnly } = useLibraryContext(); const { sidebarComponentInfo, sidebarAction } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; const containerId = sidebarComponentInfo?.id; // istanbul ignore if: this should never happen diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index df70791e14..e143e3ff3c 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -9,7 +9,7 @@ import { IconButton, useToggle, } from '@openedx/paragon'; -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { Link } from 'react-router-dom'; import { MoreVert } from '@openedx/paragon/icons'; @@ -17,7 +17,6 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont import { useLibraryContext } from '../common/context/LibraryContext'; import { type UnitInfoTab, - SidebarActions, UNIT_INFO_TABS, isUnitInfoTab, useSidebarContext, @@ -81,9 +80,8 @@ const UnitInfo = () => { sidebarTab, setSidebarTab, sidebarComponentInfo, - sidebarAction, + resetSidebarAction, } = useSidebarContext(); - const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; const { insideUnit } = useLibraryRoutes(); const tab: UnitInfoTab = ( @@ -96,6 +94,12 @@ const UnitInfo = () => { const showOpenUnitButton = !insideUnit && !componentPickerMode; + /* istanbul ignore next */ + const handleTabChange = (newTab: UnitInfoTab) => { + resetSidebarAction(); + setSidebarTab(newTab); + }; + const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => { if (hiddenTabs.includes(infoTab)) { // For some reason, returning anything other than empty list breaks the tab style @@ -117,13 +121,6 @@ const UnitInfo = () => { } }, [publishContainer]); - useEffect(() => { - // Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo - if (jumpToCollections) { - setSidebarTab(UNIT_INFO_TABS.Manage); - } - }, [jumpToCollections, setSidebarTab]); - if (!container || !unitId) { return null; } @@ -163,7 +160,7 @@ const UnitInfo = () => { className="my-3 d-flex justify-content-around" defaultActiveKey={defaultTab.unit} activeKey={tab} - onSelect={setSidebarTab} + onSelect={handleTabChange} > {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} {renderTab(UNIT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 220ce0a3f9..d4a737f115 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -259,6 +259,9 @@ export interface LibraryBlockMetadata { modified: string | null; tagsCount: number; collections: CollectionMetadata[]; + // Local only variable set to true when a new block is added + // NOTE: Currently only updated when a new component is added inside a unit + isNew?: boolean; } export interface UpdateLibraryDataRequest { diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index b9a825b34f..7ccc0a9392 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -5,6 +5,7 @@ import { useQueryClient, type Query, type QueryClient, + replaceEqualDeep, } from '@tanstack/react-query'; import { useCallback } from 'react'; @@ -632,6 +633,22 @@ export const useContainerChildren = (containerId?: string) => ( enabled: !!containerId, queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!), queryFn: () => api.getLibraryContainerChildren(containerId!), + structuralSharing: (oldData: api.LibraryBlockMetadata[], newData: api.LibraryBlockMetadata[]) => { + // This just sets `isNew` flag to new children components + if (oldData) { + const oldDataIds = oldData.map((obj) => obj.id); + // eslint-disable-next-line no-param-reassign + newData = newData.map((newObj) => { + if (!oldDataIds.includes(newObj.id)) { + // Set isNew = true if we have new child on refetch + // eslint-disable-next-line no-param-reassign + newObj.isNew = true; + } + return newObj; + }); + } + return replaceEqualDeep(oldData, newData); + }, }) ); diff --git a/src/library-authoring/generic/manage-collections/ManageCollections.tsx b/src/library-authoring/generic/manage-collections/ManageCollections.tsx index 96591b3274..4cafadfa47 100644 --- a/src/library-authoring/generic/manage-collections/ManageCollections.tsx +++ b/src/library-authoring/generic/manage-collections/ManageCollections.tsx @@ -206,7 +206,7 @@ const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }: const collectionNames = collections.map((collection) => collection.title); return ( - sidebarAction === SidebarActions.JumpToAddCollections + sidebarAction === SidebarActions.JumpToManageCollections ? ( setSidebarAction(SidebarActions.JumpToAddCollections)} + onManageClick={() => setSidebarAction(SidebarActions.JumpToManageCollections)} /> ) ); diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index a4443e73e1..aeae340979 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -10,11 +10,17 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { AddContent, AddContentHeader } from '../add-content'; import { CollectionInfo, CollectionInfoHeader } from '../collections'; import { ContainerInfoHeader, UnitInfo } from '../containers'; -import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; +import { + COMPONENT_INFO_TABS, SidebarActions, SidebarBodyComponentId, useSidebarContext, +} from '../common/context/SidebarContext'; import { ComponentInfo, ComponentInfoHeader } from '../component-info'; import { LibraryInfo, LibraryInfoHeader } from '../library-info'; import messages from '../messages'; +interface LibrarySidebarProps { + onSidebarClose?: () => void; +} + /** * Sidebar container for library pages. * @@ -24,9 +30,25 @@ import messages from '../messages'; * You can add more components in `bodyComponentMap`. * Use the returned actions to open and close this sidebar. */ -const LibrarySidebar = () => { +const LibrarySidebar = ({ onSidebarClose }: LibrarySidebarProps) => { const intl = useIntl(); - const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext(); + const { + sidebarAction, + setSidebarTab, + sidebarComponentInfo, + closeLibrarySidebar, + } = useSidebarContext(); + const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections; + const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags; + + React.useEffect(() => { + // Show Manage tab if JumpToManageCollections or JumpToManageTags action is set + if (jumpToCollections || jumpToTags) { + // COMPONENT_INFO_TABS.Manage works for containers as well as its value + // is same as UNIT_INFO_TABS.Manage. + setSidebarTab(COMPONENT_INFO_TABS.Manage); + } + }, [jumpToCollections, setSidebarTab, jumpToTags]); const bodyComponentMap = { [SidebarBodyComponentId.AddContent]: , @@ -49,6 +71,11 @@ const LibrarySidebar = () => { const buildBody = () : React.ReactNode => bodyComponentMap[sidebarComponentInfo?.type || 'unknown']; const buildHeader = (): React.ReactNode => headerComponentMap[sidebarComponentInfo?.type || 'unknown']; + const handleSidebarClose = () => { + closeLibrarySidebar(); + onSidebarClose?.(); + }; + return ( @@ -58,7 +85,7 @@ const LibrarySidebar = () => { src={Close} iconAs={Icon} alt={intl.formatMessage(messages.closeButtonAlt)} - onClick={closeLibrarySidebar} + onClick={handleSidebarClose} size="inline" /> diff --git a/src/library-authoring/routes.test.tsx b/src/library-authoring/routes.test.tsx index 8a58f3a41f..ad03cbc4c3 100644 --- a/src/library-authoring/routes.test.tsx +++ b/src/library-authoring/routes.test.tsx @@ -15,6 +15,14 @@ jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, })); +jest.mock('./common/context/LibraryContext', () => ({ + ...jest.requireActual('./common/context/LibraryContext'), + useLibraryContext: () => ({ + setComponentId: jest.fn(), + setUnitId: jest.fn(), + setCollectionId: jest.fn(), + }), +})); mockContentLibrary.applyMock(); diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 1bee5219ab..5103684561 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -11,6 +11,7 @@ import { useSearchParams, type PathMatch, } from 'react-router-dom'; +import { useLibraryContext } from './common/context/LibraryContext'; export const BASE_ROUTE = '/library/:libraryId'; @@ -66,6 +67,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const params = useParams(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); + const { setComponentId, setUnitId, setCollectionId } = useLibraryContext(); const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname); const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname); @@ -99,6 +101,18 @@ export const useLibraryRoutes = (): LibraryRoutesData => { }; let route: string; + // Update componentId, unitId, collectionId in library context if is not undefined. + // Ids can be cleared from route by passing in empty string so we need to set it. + if (componentId !== undefined) { + setComponentId(componentId); + } + if (unitId !== undefined) { + setUnitId(unitId); + } + if (collectionId !== undefined) { + setCollectionId(collectionId); + } + // Providing contentType overrides the current route so we can change tabs. if (contentType === ContentType.components) { route = ROUTES.COMPONENTS; @@ -158,7 +172,15 @@ export const useLibraryRoutes = (): LibraryRoutesData => { pathname: newPath, search: searchParams.toString(), }); - }, [navigate, params, searchParams, pathname]); + }, [ + navigate, + params, + searchParams, + pathname, + setComponentId, + setUnitId, + setCollectionId, + ]); return { navigateTo, diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 43e985eb8c..aa2b01c888 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -1,12 +1,12 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - ActionRow, Badge, Button, Icon, IconButton, Stack, useToggle, + ActionRow, Badge, Button, Icon, Stack, useToggle, } from '@openedx/paragon'; -import { Add, Description, DragIndicator } from '@openedx/paragon/icons'; -import { useQueryClient } from '@tanstack/react-query'; +import { Add, Description } from '@openedx/paragon/icons'; import classNames from 'classnames'; -import { useContext, useEffect, useState } from 'react'; -import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; +import { + useCallback, useContext, useEffect, useState, +} from 'react'; import { blockTypes } from '../../editors/data/constants/app'; import DraggableList, { SortableItem } from '../../generic/DraggableList'; @@ -22,7 +22,6 @@ import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; import { - libraryAuthoringQueryKeys, useContainerChildren, useUpdateContainerChildren, useUpdateXBlockFields, @@ -30,9 +29,10 @@ import { import { LibraryBlock } from '../LibraryBlock'; import { useLibraryRoutes, ContentType } from '../routes'; import messages from './messages'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; import { ToastContext } from '../../generic/toast-context'; import { canEditComponent } from '../components/ComponentEditorModal'; +import { useRunOnNextRender } from '../../utils'; /** Components that need large min height in preview */ const LARGE_COMPONENTS = [ @@ -43,17 +43,24 @@ const LARGE_COMPONENTS = [ 'lti_consumer', ]; -interface BlockHeaderProps { - block: LibraryBlockMetadata; - onTagClick: () => void; +interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata { + originalId: string; +} + +interface ComponentBlockProps { + block: LibraryBlockMetadataWithUniqueId; + preview?: boolean; + isDragging?: boolean; } -/** Component header, split out to reuse in drag overlay */ -const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => { +/** Component header */ +const BlockHeader = ({ block }: ComponentBlockProps) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); + const { navigateTo } = useLibraryRoutes(); + const { openComponentInfoSidebar, setSidebarAction } = useSidebarContext(); - const updateMutation = useUpdateXBlockFields(block.id); + const updateMutation = useUpdateXBlockFields(block.originalId); const handleSaveDisplayName = (newDisplayName: string) => { updateMutation.mutateAsync({ @@ -67,9 +74,30 @@ const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => { }); }; + /* istanbul ignore next */ + const scheduleJumpToTags = useRunOnNextRender(() => { + // TODO: Ugly hack to make sure sidebar shows manage tags section + // This needs to run after all changes to url takes place to avoid conflicts. + setTimeout(() => setSidebarAction(SidebarActions.JumpToManageTags), 250); + }); + + /* istanbul ignore next */ + const jumpToManageTags = () => { + navigateTo({ componentId: block.originalId }); + openComponentInfoSidebar(block.originalId); + scheduleJumpToTags(); + }; + return ( <> - + e.stopPropagation()} + > { /> - + e.stopPropagation()} + > {block.hasUnpublishedChanges && ( - + )} - - + + ); }; -interface LibraryUnitBlocksProps { - /** set to true if it is rendered as preview - * This disables drag and drop - */ - preview?: boolean; -} - -export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { - const intl = useIntl(); - const [orderedBlocks, setOrderedBlocks] = useState([]); - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); - - const [hidePreviewFor, setHidePreviewFor] = useState(null); +/** ComponentBlock to render preview of given component under Unit */ +const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => { + const { showOnlyPublished } = useLibraryContext(); const { navigateTo } = useLibraryRoutes(); - const { showToast } = useContext(ToastContext); const { - unitId, - showOnlyPublished, - componentId, - readOnly, - setComponentId, - openComponentEditor, + unitId, collectionId, componentId, openComponentEditor, } = useLibraryContext(); - const { - openAddContentSidebar, - } = useSidebarContext(); - - const queryClient = useQueryClient(); - const orderMutator = useUpdateContainerChildren(unitId); - const { - data: blocks, - isLoading, - isError, - error, - } = useContainerChildren(unitId); - - useEffect(() => setOrderedBlocks(blocks || []), [blocks]); - - if (isLoading) { - return ; - } - - if (isError) { - // istanbul ignore next - return ; - } - - const handleReorder = () => async (newOrder: LibraryBlockMetadata[]) => { - const usageKeys = newOrder.map((o) => o.id); - try { - await orderMutator.mutateAsync(usageKeys); - showToast(intl.formatMessage(messages.orderUpdatedMsg)); - } catch (e) { - showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); - } - }; - - const onTagSidebarClose = () => { - queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId!)); - closeManageTagsDrawer(); - }; + const { openInfoSidebar } = useSidebarContext(); - const handleComponentSelection = (block: LibraryBlockMetadata, numberOfClicks: number) => { - setComponentId(block.id); - navigateTo({ componentId: block.id }); - const canEdit = canEditComponent(block.id); + const handleComponentSelection = useCallback((numberOfClicks: number) => { + navigateTo({ componentId: block.originalId }); + const canEdit = canEditComponent(block.originalId); if (numberOfClicks > 1 && canEdit) { // Open editor on double click. - openComponentEditor(block.id); + openComponentEditor(block.originalId); + } else { + // open current component sidebar + openInfoSidebar(block.originalId, collectionId, unitId); } - }; + }, [block, collectionId, unitId, navigateTo, canEditComponent, openComponentEditor, openInfoSidebar]); + + useEffect(() => { + if (block.isNew) { + handleComponentSelection(1); + } + }, [block]); /* istanbul ignore next */ - const calculateMinHeight = (block: LibraryBlockMetadata) => { + const calculateMinHeight = () => { if (LARGE_COMPONENTS.includes(block.blockType)) { return '700px'; } return '200px'; }; - const renderOverlay = (activeId: string | null) => { - if (!activeId) { - return null; - } - const block = orderedBlocks?.find((val) => val.id === activeId); - if (!block) { - return null; + const getComponentStyle = useCallback(() => { + if (isDragging) { + return { + outline: '2px dashed gray', + maxHeight: '200px', + overflowY: 'hidden', + }; + } if (componentId === block.originalId) { + return { + outline: '2px solid black', + }; } - return ( - - - - - ); - }; + return {}; + }, [isDragging, componentId, block]); - const renderedBlocks = orderedBlocks?.map((block, idx) => ( - // A container can have multiple instances of the same block - // eslint-disable-next-line react/no-array-index-key - + return ( + } + componentStyle={getComponentStyle()} + actions={} actionStyle={{ borderRadius: '8px 8px 0px 0px', padding: '0.5rem 1rem', background: '#FBFAF9', borderBottom: 'solid 1px #E1DDDB', - outline: hidePreviewFor === block.id && '2px dashed gray', }} isClickable - onClick={(e: { detail: number; }) => handleComponentSelection(block, e.detail)} + onClick={(e: { detail: number; }) => handleComponentSelection(e.detail)} disabled={preview} > - {hidePreviewFor !== block.id && ( -
- -
- )} + // Prevent parent card from being clicked. + onClick={(e) => e.stopPropagation()} + > + +
- )); + ); +}; + +interface LibraryUnitBlocksProps { + /** set to true if it is rendered as preview + * This disables drag and drop + */ + preview?: boolean; +} + +export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { + const intl = useIntl(); + const [orderedBlocks, setOrderedBlocks] = useState([]); + const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + + const [hidePreviewFor, setHidePreviewFor] = useState(null); + const { showToast } = useContext(ToastContext); + + const { unitId, readOnly } = useLibraryContext(); + + const { openAddContentSidebar } = useSidebarContext(); + + const orderMutator = useUpdateContainerChildren(unitId); + const { + data: blocks, + isLoading, + isError, + error, + } = useContainerChildren(unitId); + + const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => { + if (!newOrder) { + return; + } + const usageKeys = newOrder.map((o) => o.originalId); + try { + await orderMutator.mutateAsync(usageKeys); + showToast(intl.formatMessage(messages.orderUpdatedMsg)); + } catch (e) { + showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); + } + }, [orderMutator]); + + useEffect(() => { + // Create new ids which are unique using index. + // This is required to support multiple components with same id under a unit. + const newBlocks = blocks?.map((block, idx) => { + const newBlock: LibraryBlockMetadataWithUniqueId = { + ...block, + id: `${block.id}----${idx}`, + originalId: block.id, + }; + return newBlock; + }); + return setOrderedBlocks(newBlocks || []); + }, [blocks, setOrderedBlocks]); + + if (isLoading) { + return ; + } + + if (isError) { + // istanbul ignore next + return ; + } return (
@@ -241,11 +287,19 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder} - renderOverlay={renderOverlay} activeId={hidePreviewFor} setActiveId={setHidePreviewFor} > - {renderedBlocks} + {orderedBlocks?.map((block, idx) => ( + // A container can have multiple instances of the same block + // eslint-disable-next-line react/no-array-index-key + + ))} {!preview && (
@@ -281,11 +335,6 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => {
)} - ); }; diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index f90dcedd12..6d4ea7e31c 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -42,14 +42,14 @@ mockContentLibrary.applyMock(); mockXBlockFields.applyMock(); mockLibraryBlockMetadata.applyMock(); -const closestCenter = jest.fn(); -jest.mock('@dnd-kit/core', () => ({ - ...jest.requireActual('@dnd-kit/core'), +const verticalSortableListCollisionDetection = jest.fn(); +jest.mock('../../generic/DraggableList/verticalSortableList', () => ({ + ...jest.requireActual('../../generic/DraggableList/verticalSortableList'), // Since jsdom (used by jest) does not support getBoundingClientRect function // which is required for drag-n-drop calculations, we mock closestCorners fn // from dnd-kit to return collided elements as per the test. This allows us to // test all drag-n-drop handlers. - closestCenter: () => closestCenter(), + verticalSortableListCollisionDetection: () => verticalSortableListCollisionDetection(), })); describe('', () => { @@ -187,9 +187,9 @@ describe('', () => { it('should open and close component sidebar on component selection', async () => { renderLibraryUnitPage(); - const component = await screen.findByText('text block 0'); - userEvent.click(component); + // Card is 3 levels up the component name div + userEvent.click(component.parentElement!.parentElement!.parentElement!); const sidebar = await screen.findByTestId('library-sidebar'); const { findByRole, findByText } = within(sidebar); @@ -276,14 +276,26 @@ describe('', () => { axiosMock .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) .reply(200); - closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + }); + + it('should cancel update order api on cancelling dragging component', async () => { + renderLibraryUnitPage(); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) + .reply(200); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); - await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Escape' })); + await waitFor(() => expect(mockShowToast).not.toHaveBeenLastCalledWith('Order updated')); }); it('should show toast error message on update order failure', async () => { @@ -292,13 +304,11 @@ describe('', () => { axiosMock .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) .reply(500); - closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); - await act(async () => { - fireEvent.keyDown(firstDragHandle, { code: 'Space' }); - }); + verticalSortableListCollisionDetection.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1----1' }]); await act(async () => { fireEvent.keyDown(firstDragHandle, { code: 'Space' }); }); + setTimeout(() => fireEvent.keyDown(firstDragHandle, { code: 'Space' })); await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order')); }); @@ -311,7 +321,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -342,7 +352,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -360,7 +370,7 @@ describe('', () => { const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; fireEvent.click(menu); - const removeButton = await screen.getByText('Remove from unit'); + const removeButton = await screen.findByText('Remove from unit'); fireEvent.click(removeButton); await waitFor(() => { @@ -388,7 +398,7 @@ describe('', () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0'); - userEvent.click(component); + userEvent.click(component.parentElement!.parentElement!.parentElement!); const sidebar = await screen.findByTestId('library-sidebar'); const { findByRole, findByText } = within(sidebar); @@ -409,7 +419,7 @@ describe('', () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0'); // trigger double click - userEvent.click(component, undefined, { clickCount: 2 }); + userEvent.click(component.parentElement!.parentElement!.parentElement!, undefined, { clickCount: 2 }); expect(await screen.findByRole('dialog', { name: 'Editor Dialog' })).toBeInTheDocument(); }); }); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index a84bed2a9f..a103a98966 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -91,7 +91,7 @@ const HeaderActions = () => { } else { openUnitInfoSidebar(unitId); } - navigateTo({ unitId }); + navigateTo({ unitId, componentId: '' }); }, [unitId, infoSidebarIsOpen]); return ( @@ -123,15 +123,24 @@ export const LibraryUnitPage = () => { const { libraryId, unitId, - collectionId, componentId, + collectionId, } = useLibraryContext(); const { - sidebarComponentInfo, openInfoSidebar, + sidebarComponentInfo, setDefaultTab, setHiddenTabs, } = useSidebarContext(); + const { navigateTo } = useLibraryRoutes(); + + // Open unit or component sidebar on mount + useEffect(() => { + // includes componentId to open correct sidebar on page mount from url + openInfoSidebar(componentId, collectionId, unitId); + // avoid including componentId in dependencies to prevent flicker on closing sidebar. + // See below useEffect that clears componentId on closing sidebar. + }, [unitId, collectionId]); useEffect(() => { setDefaultTab({ @@ -150,10 +159,6 @@ export const LibraryUnitPage = () => { }; }, [setDefaultTab, setHiddenTabs]); - useEffect(() => { - openInfoSidebar(componentId, collectionId, unitId); - }, [componentId, unitId, collectionId]); - if (!unitId || !libraryId) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. throw new Error('Rendered without unitId or libraryId URL parameter'); @@ -232,7 +237,7 @@ export const LibraryUnitPage = () => { className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar" > - + navigateTo({ componentId: '' })} /> )} diff --git a/src/library-authoring/units/index.scss b/src/library-authoring/units/index.scss index 91b5d78b1f..038de081af 100644 --- a/src/library-authoring/units/index.scss +++ b/src/library-authoring/units/index.scss @@ -14,6 +14,11 @@ &:focus { // this is required for clicks to be passed to underlying iframe component pointer-events: none; + outline: solid 1px $dark-500; + } + + &::before { + border: none; } } diff --git a/src/utils.js b/src/utils.js index 00daa2be31..d763a6246f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useState, useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useMediaQuery } from 'react-responsive'; import * as Yup from 'yup'; @@ -301,3 +301,22 @@ export const getFileSizeToClosestByte = (fileSize) => { const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2); return `${fileSizeFixedDecimal} ${units[divides]}`; }; + +/** +* A generic hook to run callback on next render cycle. +* @param {} callback - Callback function that needs to be run later +*/ +export const useRunOnNextRender = (callback) => { + const [scheduled, setScheduled] = useState(false); + + useEffect(() => { + if (!scheduled) { + return; + } + + setScheduled(false); + callback(); + }, [scheduled]); + + return () => setScheduled(true); +}; From 6c4634ebbebccc9bf0d9202171a12c625f085c71 Mon Sep 17 00:00:00 2001 From: Jillian Date: Sat, 10 May 2025 01:33:58 +0930 Subject: [PATCH 06/37] fix: invalidate search results when publishing all changes in library (#1925) (#1927) (cherry picked from commit cdb80166577668104fcd64de8b2ce9fbb21c2c4f) Co-authored-by: Braden MacDonald --- src/library-authoring/data/apiHooks.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 7ccc0a9392..a74fc5097e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -91,7 +91,11 @@ export const xblockQueryKeys = { componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'], componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'], - /** Predicate used to invalidate all metadata only */ + /** + * Predicate used to invalidate all metadata only (not OLX, fields, assets, etc.). + * Affects all libraries; we could do a more complex version that affects only one library, but it would require + * introspecting the usage keys. + */ allComponentMetadata: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'componentMetadata', }; @@ -208,23 +212,32 @@ export const useContentLibraryV2List = (customParams: api.GetLibrariesV2CustomPa }) ); +/** Publish all changes in the library. */ export const useCommitLibraryChanges = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.commitLibraryChanges, onSettled: (_data, _error, libraryId) => { + // Invalidate all content-related metadata and search results for the whole library. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); }, }); }; +/** Discard all un-published changes in the library */ export const useRevertLibraryChanges = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.revertLibraryChanges, onSettled: (_data, _error, libraryId) => { + // Invalidate all content-related metadata and search results for the whole library. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); }, }); }; From a162929fd7d0419bde9599a18b997baf2154d1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 12 May 2025 14:05:28 -0300 Subject: [PATCH 07/37] fix: improve focus/selected style on library authoring (#1918) (#1930) Improves the focus and selected styles from the LibraryPage and UnitPage. --- src/generic/DraggableList/SortableItem.jsx | 4 +- .../components/BaseCard.scss | 56 +++++++++++++++---- src/library-authoring/components/BaseCard.tsx | 7 ++- .../components/CollectionCard.tsx | 8 ++- .../components/ComponentCard.tsx | 8 ++- .../components/ContainerCard.tsx | 8 ++- .../units/LibraryUnitBlocks.tsx | 8 ++- src/library-authoring/units/index.scss | 17 ++++++ 8 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/generic/DraggableList/SortableItem.jsx b/src/generic/DraggableList/SortableItem.jsx index 57ee5d00ca..cf488d8b50 100644 --- a/src/generic/DraggableList/SortableItem.jsx +++ b/src/generic/DraggableList/SortableItem.jsx @@ -18,6 +18,7 @@ const SortableItem = ({ isClickable, onClick, disabled, + cardClassName = '', // injected intl, }) => { @@ -52,7 +53,7 @@ const SortableItem = ({ > @@ -94,6 +95,7 @@ SortableItem.propTypes = { isClickable: PropTypes.bool, onClick: PropTypes.func, disabled: PropTypes.bool, + cardClassName: PropTypes.string, // injected intl: intlShape.isRequired, }; diff --git a/src/library-authoring/components/BaseCard.scss b/src/library-authoring/components/BaseCard.scss index 3a13bd14cb..84f0453c89 100644 --- a/src/library-authoring/components/BaseCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -2,24 +2,56 @@ .pgn__card { height: 100%; min-width: 15rem; - } - .library-item-header { - border-top-left-radius: .375rem; - border-top-right-radius: .375rem; - padding: 0 .5rem 0 1.25rem; + &::before { + border: none !important; // Remove default focus + } + + &.selected:not(:focus) { + border: 2px $gray-700 solid; + + .library-item-header { + border-top-left-radius: calc(.375rem - 2px); + border-top-right-radius: calc(.375rem - 2px); + } + } + + &.selected:focus { + border: 3px $gray-700 solid; - .library-item-header-icon { - width: 2.3rem; - height: 2.3rem; + .library-item-header { + border-top-left-radius: calc(.375rem - 3px); + border-top-right-radius: calc(.375rem - 3px); + } } - .pgn__card-header-content { - margin-top: .55rem; + &:not(.selected):focus { + outline: 1px $gray-200 solid; + outline-offset: 2px; } - .pgn__card-header-actions { - margin: .25rem 0 .25rem 1rem; + &:not(.selected) { + .library-item-header { + border-top-left-radius: .375rem; + border-top-right-radius: .375rem; + } + } + + .library-item-header { + padding: 0 .5rem 0 1.25rem; + + .library-item-header-icon { + width: 2.3rem; + height: 2.3rem; + } + + .pgn__card-header-content { + margin-top: .55rem; + } + + .pgn__card-header-actions { + margin: .25rem 0 .25rem 1rem; + } } } diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index 2b15937891..591fc3be6a 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -22,7 +22,8 @@ type BaseCardProps = { tags: ContentHitTags; actions: React.ReactNode; hasUnpublishedChanges?: boolean; - onSelect: () => void + onSelect: () => void; + selected?: boolean; }; const BaseCard = ({ @@ -33,6 +34,7 @@ const BaseCard = ({ tags, actions, onSelect, + selected = false, ...props } : BaseCardProps) => { const tagCount = useMemo(() => { @@ -47,7 +49,7 @@ const BaseCard = ({ const intl = useIntl(); return ( - + { const { componentPickerMode } = useComponentPickerContext(); const { showOnlyPublished } = useLibraryContext(); - const { openCollectionInfoSidebar } = useSidebarContext(); + const { openCollectionInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { type: itemType, @@ -132,6 +132,9 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { const { displayName = '', description = '' } = formatted; + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.CollectionInfo + && sidebarComponentInfo.id === collectionId; + const { navigateTo } = useLibraryRoutes(); const openCollection = useCallback(() => { openCollectionInfoSidebar(collectionId); @@ -154,6 +157,7 @@ const CollectionCard = ({ hit } : CollectionCardProps) => { )} onSelect={openCollection} + selected={selected} /> ); }; diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 86dccbfd4f..4b58147fbf 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -6,7 +6,7 @@ import { import { type ContentHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; @@ -18,7 +18,7 @@ type ComponentCardProps = { const ComponentCard = ({ hit }: ComponentCardProps) => { const { showOnlyPublished } = useLibraryContext(); - const { openComponentInfoSidebar } = useSidebarContext(); + const { openComponentInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { componentPickerMode } = useComponentPickerContext(); const { @@ -44,6 +44,9 @@ const ComponentCard = ({ hit }: ComponentCardProps) => { } }, [usageKey, navigateTo, openComponentInfoSidebar]); + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo + && sidebarComponentInfo.id === usageKey; + return ( { )} hasUnpublishedChanges={publishStatus !== PublishStatus.Published} onSelect={openComponent} + selected={selected} /> ); }; diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 97e30e3823..1617c9d1fb 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -17,7 +17,7 @@ import { ToastContext } from '../../generic/toast-context'; import { type ContainerHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { useRemoveItemsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; import AddComponentWidget from './AddComponentWidget'; @@ -174,7 +174,7 @@ type ContainerCardProps = { const ContainerCard = ({ hit } : ContainerCardProps) => { const { componentPickerMode } = useComponentPickerContext(); const { setUnitId, showOnlyPublished } = useLibraryContext(); - const { openUnitInfoSidebar } = useSidebarContext(); + const { openUnitInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const { blockType: itemType, @@ -199,6 +199,9 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys ) ?? []; + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.UnitInfo + && sidebarComponentInfo.id === unitId; + const { navigateTo } = useLibraryRoutes(); const openContainer = useCallback(() => { @@ -227,6 +230,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { )} hasUnpublishedChanges={publishStatus !== PublishStatus.Published} onSelect={openContainer} + selected={selected} /> ); }; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index aa2b01c888..f518e976d7 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -29,7 +29,7 @@ import { import { LibraryBlock } from '../LibraryBlock'; import { useLibraryRoutes, ContentType } from '../routes'; import messages from './messages'; -import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; +import { SidebarActions, SidebarBodyComponentId, useSidebarContext } from '../common/context/SidebarContext'; import { ToastContext } from '../../generic/toast-context'; import { canEditComponent } from '../components/ComponentEditorModal'; import { useRunOnNextRender } from '../../utils'; @@ -139,7 +139,7 @@ const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => unitId, collectionId, componentId, openComponentEditor, } = useLibraryContext(); - const { openInfoSidebar } = useSidebarContext(); + const { openInfoSidebar, sidebarComponentInfo } = useSidebarContext(); const handleComponentSelection = useCallback((numberOfClicks: number) => { navigateTo({ componentId: block.originalId }); @@ -182,6 +182,9 @@ const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => return {}; }, [isDragging, componentId, block]); + const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo + && sidebarComponentInfo?.id === block.id; + return ( isClickable onClick={(e: { detail: number; }) => handleComponentSelection(e.detail)} disabled={preview} + cardClassName={selected ? 'selected' : undefined} > {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
Date: Mon, 12 May 2025 16:42:34 -0300 Subject: [PATCH 08/37] fix: review/sync bugs [FC-0083] (#1905) (#1941) Fixes issues related to component libraries' review/sync flow * Inconsistent sync pane title versions * Library content shown in preview warning only appears in review changes modal when that modal is opened from the review tab * Some new changes only appear within library review tab on scroll at top of list * Vertically misaligned sync icon in review changes message on course outline * Show available updates whenever content is updated, regardless of number of updates available --- src/course-libraries/CourseLibraries.test.tsx | 48 +++++- src/course-libraries/CourseLibraries.tsx | 2 +- src/course-libraries/OutOfSyncAlert.tsx | 20 +-- src/course-libraries/ReviewTabContent.tsx | 87 +++------- .../__mocks__/linkCourseSummary.json | 9 +- .../__mocks__/publishableEntityLinks.json | 151 +++++++++--------- src/course-libraries/data/api.mocks.ts | 20 +-- src/course-libraries/data/api.ts | 21 +-- src/course-libraries/data/apiHooks.test.tsx | 19 +-- src/course-libraries/data/apiHooks.ts | 36 +---- src/course-libraries/messages.ts | 5 - src/course-outline/unit-card/UnitCard.jsx | 14 +- .../preview-changes/index.test.tsx | 21 +-- src/course-unit/preview-changes/index.tsx | 56 +++---- src/course-unit/preview-changes/messages.ts | 10 +- src/generic/alert-message/index.scss | 6 + src/generic/styles.scss | 3 +- .../component-info/ComponentDetails.test.tsx | 4 +- .../component-info/ComponentInfo.test.tsx | 4 +- .../component-info/ComponentUsage.tsx | 4 +- .../components/PublishConfirmationModal.tsx | 4 +- src/library-authoring/data/api.mocks.ts | 20 +-- 22 files changed, 232 insertions(+), 332 deletions(-) create mode 100644 src/generic/alert-message/index.scss diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index 5df2fe3134..4c44144246 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -118,6 +118,46 @@ describe('', () => { userEvent.click(reviewActionBtn); expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); }); + + it('show alert if max lastPublishedDate is greated than the local storage value', async () => { + const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z'); + localStorage.setItem( + `outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`, + String(lastPublishedDate.getTime() - 1000), + ); + + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + // review tab should be open by default as outOfSyncCount is greater than 0 + expect(reviewTab).toHaveAttribute('aria-selected', 'true'); + + userEvent.click(allTab); + const alert = await screen.findByRole('alert'); + expect(await within(alert).findByText( + '5 library components are out of sync. Review updates to accept or ignore changes', + )).toBeInTheDocument(); + }); + + it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => { + const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z'); + localStorage.setItem( + `outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`, + String(lastPublishedDate.getTime() + 1000), + ); + + await renderCourseLibrariesPage(mockGetEntityLinks.courseKey); + const allTab = await screen.findByRole('tab', { name: 'Libraries' }); + const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' }); + // review tab should be open by default as outOfSyncCount is greater than 0 + expect(reviewTab).toHaveAttribute('aria-selected', 'true'); + userEvent.click(allTab); + expect(allTab).toHaveAttribute('aria-selected', 'true'); + + screen.logTestingPlaygroundURL(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); }); describe('', () => { @@ -160,7 +200,7 @@ describe('', () => { it('update changes works', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); @@ -176,7 +216,7 @@ describe('', () => { it('update changes works in preview modal', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); @@ -195,7 +235,7 @@ describe('', () => { it('ignore change works', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); @@ -218,7 +258,7 @@ describe('', () => { it('ignore change works in preview', async () => { const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); - const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; + const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey; axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index 3c1a0730fe..2d0cfadf0f 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -164,7 +164,7 @@ export const CourseLibraries: React.FC = ({ courseId }) => { if (tabKey !== CourseLibraryTabs.review) { return null; } - if (!outOfSyncCount || outOfSyncCount === 0) { + if (!outOfSyncCount) { return ( diff --git a/src/course-libraries/OutOfSyncAlert.tsx b/src/course-libraries/OutOfSyncAlert.tsx index 27b88f2c83..da36c40869 100644 --- a/src/course-libraries/OutOfSyncAlert.tsx +++ b/src/course-libraries/OutOfSyncAlert.tsx @@ -18,12 +18,11 @@ interface OutOfSyncAlertProps { * in course can be updated. Following are the conditions for displaying the alert. * * * The alert is displayed if components are out of sync. -* * If the user clicks on dismiss button, the state is stored in localstorage of user -* in this format: outOfSyncCountAlert-${courseId} = . -* * If the number of sync components don't change for the course and the user opens outline +* * If the user clicks on dismiss button, the state of dismiss is stored in localstorage of user +* in this format: outOfSyncCountAlert-${courseId} = . +* * If there are not new published components for the course and the user opens outline * in the same browser, they don't see the alert again. -* * If the number changes, i.e., if a new component is out of sync or the user updates or ignores -* a component, the alert is displayed again. +* * If there is a new published component upstream, the alert is displayed again. */ export const OutOfSyncAlert: React.FC = ({ showAlert, @@ -35,6 +34,8 @@ export const OutOfSyncAlert: React.FC = ({ const intl = useIntl(); const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId); const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0); + const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime()) + .reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0); const alertKey = `outOfSyncCountAlert-${courseId}`; useEffect(() => { @@ -46,13 +47,14 @@ export const OutOfSyncAlert: React.FC = ({ setShowAlert(false); return; } - const dismissedAlert = localStorage.getItem(alertKey); - setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount); - }, [outOfSyncCount, isLoading, data]); + const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10); + + setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate); + }, [outOfSyncCount, lastPublishedDate, isLoading, data]); const dismissAlert = () => { setShowAlert(false); - localStorage.setItem(alertKey, String(outOfSyncCount)); + localStorage.setItem(alertKey, Date.now().toString()); onDismiss?.(); }; diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index 0c22815a3a..36634335bd 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -1,5 +1,5 @@ import React, { - useCallback, useContext, useEffect, useMemo, useState, + useCallback, useContext, useMemo, useState, } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -16,7 +16,7 @@ import { import { tail, keyBy } from 'lodash'; import { useQueryClient } from '@tanstack/react-query'; -import { Loop, Warning } from '@openedx/paragon/icons'; +import { Loop } from '@openedx/paragon/icons'; import messages from './messages'; import previewChangesMessages from '../course-unit/preview-changes/messages'; import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks'; @@ -35,7 +35,6 @@ import { useLoadOnScroll } from '../hooks'; import DeleteModal from '../generic/delete-modal/DeleteModal'; import { PublishableEntityLink } from './data/api'; import AlertError from '../generic/alert-error'; -import AlertMessage from '../generic/alert-message'; interface Props { courseId: string; @@ -100,10 +99,8 @@ const BlockCard: React.FC = ({ info, actions }) => { const ComponentReviewList = ({ outOfSyncComponents, - onSearchUpdate, }: { outOfSyncComponents: PublishableEntityLink[]; - onSearchUpdate: () => void; }) => { const intl = useIntl(); const { showToast } = useContext(ToastContext); @@ -111,23 +108,15 @@ const ComponentReviewList = ({ // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); const { - hits: downstreamInfo, + hits, isLoading: isIndexDataLoading, - searchKeywords, hasError, hasNextPage, isFetchingNextPage, fetchNextPage, - } = useSearchContext() as { - hits: ContentHit[]; - isLoading: boolean; - searchKeywords: string; - searchSortOrder: SearchSortOption; - hasError: boolean; - hasNextPage: boolean | undefined, - isFetchingNextPage: boolean; - fetchNextPage: () => void; - }; + } = useSearchContext(); + + const downstreamInfo = hits as ContentHit[]; useLoadOnScroll( hasNextPage, @@ -142,18 +131,12 @@ const ComponentReviewList = ({ ); const queryClient = useQueryClient(); - useEffect(() => { - if (searchKeywords) { - onSearchUpdate(); - } - }, [searchKeywords]); - // Toggle preview changes modal const [isModalOpen, openModal, closeModal] = useToggle(false); const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const setSeletecdBlockData = (info: ContentHit) => { + const setSelectedBlockData = useCallback((info: ContentHit) => { setBlockData({ displayName: info.displayName, downstreamBlockId: info.usageKey, @@ -161,17 +144,18 @@ const ComponentReviewList = ({ upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced, isVertical: info.blockType === 'vertical', }); - }; + }, [outOfSyncComponentsByKey]); + // Show preview changes on review const onReview = useCallback((info: ContentHit) => { - setSeletecdBlockData(info); + setSelectedBlockData(info); openModal(); - }, [setSeletecdBlockData, openModal]); + }, [setSelectedBlockData, openModal]); const onIgnoreClick = useCallback((info: ContentHit) => { - setSeletecdBlockData(info); + setSelectedBlockData(info); openConfirmModal(); - }, [setSeletecdBlockData, openConfirmModal]); + }, [setSelectedBlockData, openConfirmModal]); const reloadLinks = useCallback((usageKey: string) => { const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey; @@ -273,20 +257,14 @@ const ComponentReviewList = ({ )} /> ))} - - )} - /> + {blockData && ( + + )} { const intl = useIntl(); const { - data: linkPages, + data: outOfSyncComponents, isLoading: isSyncComponentsLoading, - hasNextPage, - isFetchingNextPage, - fetchNextPage, isError, error, } = useEntityLinks({ courseId, readyToSync: true }); - const outOfSyncComponents = useMemo( - () => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [], - [linkPages], - ); const downstreamKeys = useMemo( () => outOfSyncComponents?.map(link => link.downstreamUsageKey), [outOfSyncComponents], ); - useLoadOnScroll( - hasNextPage, - isFetchingNextPage, - fetchNextPage, - true, - ); - - const onSearchUpdate = () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - const disableSortOptions = [ SearchSortOption.RELEVANCE, SearchSortOption.OLDEST, @@ -364,7 +322,6 @@ const ReviewTabContent = ({ courseId }: Props) => { ); diff --git a/src/course-libraries/__mocks__/linkCourseSummary.json b/src/course-libraries/__mocks__/linkCourseSummary.json index 32e98e8987..05039086d2 100644 --- a/src/course-libraries/__mocks__/linkCourseSummary.json +++ b/src/course-libraries/__mocks__/linkCourseSummary.json @@ -3,17 +3,20 @@ "upstreamContextTitle": "CS problems 3", "upstreamContextKey": "lib:OpenedX:CSPROB3", "readyToSyncCount": 5, - "totalCount": 14 + "totalCount": 14, + "lastPublishedAt": "2025-05-01T20:20:44.989042Z" }, { "upstreamContextTitle": "CS problems 2", "upstreamContextKey": "lib:OpenedX:CSPROB2", "readyToSyncCount": 0, - "totalCount": 21 + "totalCount": 21, + "lastPublishedAt": "2025-05-01T21:20:44.989042Z" }, { "upstreamContextTitle": "CS problems", "upstreamContextKey": "lib:OpenedX:CSPROB", - "totalCount": 3 + "totalCount": 3, + "lastPublishedAt": "2025-05-01T22:20:44.989042Z" } ] diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json index 9988dee71a..1dac4b2dbd 100644 --- a/src/course-libraries/__mocks__/publishableEntityLinks.json +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -1,79 +1,72 @@ -{ - "count": 7, - "next": null, - "previous": null, - "num_pages": 1, - "current_page": 1, - "results": [ - { - "id": 875, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 876, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 884, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 26, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 16, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 889, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - }, - { - "id": 890, - "upstreamContextTitle": "CS problems 3", - "upstreamVersion": 10, - "readyToSync": true, - "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", - "upstreamContextKey": "lib:OpenedX:CSPROB3", - "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", - "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", - "versionSynced": 2, - "versionDeclined": null, - "created": "2025-02-08T14:07:05.588484Z", - "updated": "2025-02-08T14:07:05.588484Z" - } - ] -} +[ + { + "id": 875, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 876, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 884, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 26, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 16, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 889, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + }, + { + "id": 890, + "upstreamContextTitle": "CS problems 3", + "upstreamVersion": 10, + "readyToSync": true, + "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", + "upstreamContextKey": "lib:OpenedX:CSPROB3", + "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", + "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", + "versionSynced": 2, + "versionDeclined": null, + "created": "2025-02-08T14:07:05.588484Z", + "updated": "2025-02-08T14:07:05.588484Z" + } +] diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts index 1f4d0e5bac..3614a51517 100644 --- a/src/course-libraries/data/api.mocks.ts +++ b/src/course-libraries/data/api.mocks.ts @@ -28,27 +28,17 @@ export async function mockGetEntityLinks( case mockGetEntityLinks.courseKeyLoading: return new Promise(() => {}); case mockGetEntityLinks.courseKeyEmpty: - return Promise.resolve({ - next: null, - previous: null, - nextPageNum: null, - previousPageNum: null, - count: 0, - numPages: 0, - currentPage: 0, - results: [], - }); + return Promise.resolve([]); default: { - const { response } = mockGetEntityLinks; + let { response } = mockGetEntityLinks; if (readyToSync !== undefined) { - response.results = response.results.filter((o) => o.readyToSync === readyToSync); - response.count = response.results.length; + response = response.filter((o) => o.readyToSync === readyToSync); } return Promise.resolve(response); } } } -mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey; mockGetEntityLinks.invalidCourseKey = 'course_key_error'; mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading'; mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty'; @@ -85,7 +75,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext( return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response); } } -mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey; +mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey; mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 4dd04c9bd5..af8108c534 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -38,32 +38,13 @@ export interface PublishableEntityLinkSummary { upstreamContextTitle: string; readyToSyncCount: number; totalCount: number; + lastPublishedAt: string; } export const getEntityLinks = async ( downstreamContextKey?: string, readyToSync?: boolean, upstreamUsageKey?: string, - pageParam?: number, - pageSize?: number, -): Promise> => { - const { data } = await getAuthenticatedHttpClient() - .get(getEntityLinksByDownstreamContextUrl(), { - params: { - course_id: downstreamContextKey, - ready_to_sync: readyToSync, - upstream_usage_key: upstreamUsageKey, - page_size: pageSize, - page: pageParam, - }, - }); - return camelCaseObject(data); -}; - -export const getUnpaginatedEntityLinks = async ( - downstreamContextKey?: string, - readyToSync?: boolean, - upstreamUsageKey?: string, ): Promise => { const { data } = await getAuthenticatedHttpClient() .get(getEntityLinksByDownstreamContextUrl(), { diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx index b46f87c3fa..f1063ce803 100644 --- a/src/course-libraries/data/apiHooks.test.tsx +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { renderHook, waitFor } from '@testing-library/react'; import { getEntityLinksByDownstreamContextUrl } from './api'; -import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks'; +import { useEntityLinks } from './apiHooks'; let axiosMock: MockAdapter; @@ -39,26 +39,11 @@ describe('course libraries api hooks', () => { axiosMock.reset(); }); - it('should return paginated links for course', async () => { - const courseId = 'course-v1:some+key'; - const url = getEntityLinksByDownstreamContextUrl(); - const expectedResult = { - next: null, results: [], previous: null, total: 0, - }; - axiosMock.onGet(url).reply(200, expectedResult); - const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper }); - await waitFor(() => { - expect(result.current.isLoading).toBeFalsy(); - }); - expect(result.current.data?.pages).toEqual([expectedResult]); - expect(axiosMock.history.get[0].url).toEqual(url); - }); - it('should return links for course', async () => { const courseId = 'course-v1:some+key'; const url = getEntityLinksByDownstreamContextUrl(); axiosMock.onGet(url).reply(200, []); - const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper }); + const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper }); await waitFor(() => { expect(result.current.isLoading).toBeFalsy(); }); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 2d6b0c0444..093a63121e 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,8 +1,7 @@ import { - useInfiniteQuery, useQuery, } from '@tanstack/react-query'; -import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api'; +import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], @@ -29,39 +28,10 @@ export const courseLibrariesQueryKeys = { }; /** - * Hook to fetch publishable entity links by course key. + * Hook to fetch list of publishable entity links by course key. * (That is, get a list of the library components used in the given course.) */ export const useEntityLinks = ({ - courseId, readyToSync, upstreamUsageKey, pageSize, -}: { - courseId?: string, - readyToSync?: boolean, - upstreamUsageKey?: string, - pageSize?: number -}) => ( - useInfiniteQuery({ - queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ - courseId, - readyToSync, - upstreamUsageKey, - }), - queryFn: ({ pageParam }) => getEntityLinks( - courseId, - readyToSync, - upstreamUsageKey, - pageParam, - pageSize, - ), - getNextPageParam: (lastPage) => lastPage.nextPageNum, - enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined, - }) -); - -/** - * Hook to fetch unpaginated list of publishable entity links by course key. - */ -export const useUnpaginatedEntityLinks = ({ courseId, readyToSync, upstreamUsageKey, }: { courseId?: string, @@ -74,7 +44,7 @@ export const useUnpaginatedEntityLinks = ({ readyToSync, upstreamUsageKey, }), - queryFn: () => getUnpaginatedEntityLinks( + queryFn: () => getEntityLinks( courseId, readyToSync, upstreamUsageKey, diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts index 803084f166..8dc7ab0980 100644 --- a/src/course-libraries/messages.ts +++ b/src/course-libraries/messages.ts @@ -116,11 +116,6 @@ const messages = defineMessages({ defaultMessage: 'Something went wrong! Could not fetch results.', description: 'Generic error message displayed when fetching link data fails.', }, - olderVersionPreviewAlert: { - id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert', - defaultMessage: 'The old version preview is the previous library version', - description: 'Alert message stating that older version in preview is of library block', - }, }); export default messages; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 82df0d28fe..25c9bfd5b7 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -229,12 +229,14 @@ const UnitCard = ({
- + {blockSyncData && ( + + )} ); }; diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index d28f02575f..f88b436c73 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -12,7 +12,6 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.' import { messageTypes } from '../constants'; import { libraryBlockChangesUrl } from '../data/api'; import { ToastActionData } from '../../generic/toast-context'; -import { getLibraryBlockMetadataUrl, getLibraryContainerApiUrl } from '../../library-authoring/data/api'; const usageKey = 'some-id'; const defaultEventData: LibraryChangesMessageData = { @@ -66,7 +65,7 @@ describe('', () => { expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument(); }); - it('renders displayName for units', async () => { + it('renders default displayName for units with no displayName', async () => { render({ ...defaultEventData, isVertical: true, displayName: '' }); expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument(); @@ -78,24 +77,6 @@ describe('', () => { expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument(); }); - it('renders both new and old title if they are different', async () => { - axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, { - displayName: 'New test block', - }); - render(); - - expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument(); - }); - - it('renders both new and old title if they are different on units', async () => { - axiosMock.onGet(getLibraryContainerApiUrl(defaultEventData.upstreamBlockId)).reply(200, { - displayName: 'New test Unit', - }); - render({ ...defaultEventData, isVertical: true, displayName: 'Test Unit' }); - - expect(await screen.findByText('Preview changes: Test Unit -> New test Unit')).toBeInTheDocument(); - }); - it('accept changes works', async () => { axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); render(); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index 157aaeab0d..c038adf089 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -1,20 +1,21 @@ -import React, { useCallback, useContext, useState } from 'react'; +import { useCallback, useContext, useState } from 'react'; import { ActionRow, Button, ModalDialog, useToggle, } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useEventListener } from '../../generic/hooks'; import { messageTypes } from '../constants'; import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget'; import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks'; +import AlertMessage from '../../generic/alert-message'; import { useIframe } from '../../generic/hooks/context/hooks'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; import messages from './messages'; import { ToastContext } from '../../generic/toast-context'; import LoadingButton from '../../generic/loading-button'; import Loading from '../../generic/Loading'; -import { useContainer, useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks'; export interface LibraryChangesMessageData { displayName: string, @@ -25,11 +26,10 @@ export interface LibraryChangesMessageData { } export interface PreviewLibraryXBlockChangesProps { - blockData?: LibraryChangesMessageData, + blockData: LibraryChangesMessageData, isModalOpen: boolean, closeModal: () => void, postChange: (accept: boolean) => void, - alertNode?: React.ReactNode, } /** @@ -41,7 +41,6 @@ export const PreviewLibraryXBlockChanges = ({ isModalOpen, closeModal, postChange, - alertNode, }: PreviewLibraryXBlockChangesProps) => { const { showToast } = useContext(ToastContext); const intl = useIntl(); @@ -49,32 +48,9 @@ export const PreviewLibraryXBlockChanges = ({ // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); - // TODO: Split into two different components to avoid making these two calls in which - // one will definitely fail - const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId); - const { data: unitMetadata } = useContainer(blockData?.upstreamBlockId); - - const metadata = blockData?.isVertical ? unitMetadata : componentMetadata; - const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); - const getTitle = useCallback(() => { - const oldName = blockData?.displayName; - const newName = metadata?.displayName; - - if (!oldName) { - if (blockData?.isVertical) { - return intl.formatMessage(messages.defaultUnitTitle); - } - return intl.formatMessage(messages.defaultComponentTitle); - } - if (oldName === newName || !newName) { - return intl.formatMessage(messages.title, { blockTitle: oldName }); - } - return intl.formatMessage(messages.diffTitle, { oldName, newName }); - }, [blockData, metadata]); - const getBody = useCallback(() => { if (!blockData) { return ; @@ -108,12 +84,21 @@ export const PreviewLibraryXBlockChanges = ({ } }, [blockData]); + const defaultTitle = intl.formatMessage( + blockData.isVertical + ? messages.defaultUnitTitle + : messages.defaultComponentTitle, + ); + const title = blockData.displayName + ? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName }) + : defaultTitle; + return ( - {getTitle()} + {title} - {alertNode} + {getBody()} @@ -186,6 +176,10 @@ const IframePreviewLibraryXBlockChanges = () => { useEventListener('message', receiveMessage); + if (!blockData) { + return null; + } + return ( {newName}', - description: 'Preview changes modal title text', - }, defaultUnitTitle: { id: 'authoring.course-unit.preview-changes.modal-default-unit-title', defaultMessage: 'Preview changes: Unit', @@ -61,6 +56,11 @@ const messages = defineMessages({ defaultMessage: 'Ignore', description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.', }, + olderVersionPreviewAlert: { + id: 'course-authoring.review-tab.preview.old-version-alert', + defaultMessage: 'The old version preview is the previous library version', + description: 'Alert message stating that older version in preview is of library block', + }, }); export default messages; diff --git a/src/generic/alert-message/index.scss b/src/generic/alert-message/index.scss new file mode 100644 index 0000000000..394e6d2598 --- /dev/null +++ b/src/generic/alert-message/index.scss @@ -0,0 +1,6 @@ +// TODO: remove this after upstream fix merging: https://github.com/openedx/paragon/pull/3562 +.alert { + .alert-message-content { + align-self: baseline; + } +} diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 00ef459221..22756b9ad1 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -12,4 +12,5 @@ @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; @import "./block-type-utils"; -@import "./modal-iframe" +@import "./modal-iframe"; +@import "./alert-message"; diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 53671cd7e4..7f7b1e3bb4 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -8,7 +8,7 @@ import { import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockContentLibrary, - mockGetUnpaginatedEntityLinks, + mockGetEntityLinks, mockLibraryBlockMetadata, mockXBlockAssets, mockXBlockOLX, @@ -21,7 +21,7 @@ mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); -mockGetUnpaginatedEntityLinks.applyMock(); +mockGetEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); const render = (usageKey: string) => baseRender(, { diff --git a/src/library-authoring/component-info/ComponentInfo.test.tsx b/src/library-authoring/component-info/ComponentInfo.test.tsx index 2206be3e50..0427e1b345 100644 --- a/src/library-authoring/component-info/ComponentInfo.test.tsx +++ b/src/library-authoring/component-info/ComponentInfo.test.tsx @@ -7,7 +7,7 @@ import { import { mockContentLibrary, mockLibraryBlockMetadata, - mockGetUnpaginatedEntityLinks, + mockGetEntityLinks, } from '../data/api.mocks'; import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; @@ -18,7 +18,7 @@ import { getXBlockPublishApiUrl } from '../data/api'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); -mockGetUnpaginatedEntityLinks.applyMock(); +mockGetEntityLinks.applyMock(); mockFetchIndexDocuments.applyMock(); jest.mock('./ComponentPreview', () => ({ __esModule: true, // Required when mocking 'default' export diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index 48c97fba8b..13e59565f1 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -2,7 +2,7 @@ import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Collapsible, Hyperlink, Stack } from '@openedx/paragon'; import { useMemo } from 'react'; -import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; +import { useEntityLinks } from '../../course-libraries/data/apiHooks'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; @@ -34,7 +34,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { isError: isErrorDownstreamLinks, error: errorDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamUsageKey: usageKey }); const downstreamKeys = useMemo( () => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [], diff --git a/src/library-authoring/components/PublishConfirmationModal.tsx b/src/library-authoring/components/PublishConfirmationModal.tsx index 8c98e234fe..eeadc84e9a 100644 --- a/src/library-authoring/components/PublishConfirmationModal.tsx +++ b/src/library-authoring/components/PublishConfirmationModal.tsx @@ -5,7 +5,7 @@ import BaseModal from '../../editors/sharedComponents/BaseModal'; import messages from './messages'; import infoMessages from '../component-info/messages'; import { ComponentUsage } from '../component-info/ComponentUsage'; -import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; +import { useEntityLinks } from '../../course-libraries/data/apiHooks'; interface PublishConfirmationModalProps { isOpen: boolean, @@ -29,7 +29,7 @@ const PublishConfirmationModal = ({ const { data: dataDownstreamLinks, isLoading: isLoadingDownstreamLinks, - } = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); + } = useEntityLinks({ upstreamUsageKey: usageKey }); const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0; diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index eb5f60188e..e267b9483a 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -675,12 +675,12 @@ mockBlockTypesMetadata.blockTypesMetadata = [ /** Apply this mock. Returns a spy object that can tell you if it's been called. */ mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockImplementation(mockBlockTypesMetadata); -export async function mockGetUnpaginatedEntityLinks( +export async function mockGetEntityLinks( _downstreamContextKey?: string, _readyToSync?: boolean, upstreamUsageKey?: string, -): ReturnType { - const thisMock = mockGetUnpaginatedEntityLinks; +): ReturnType { + const thisMock = mockGetEntityLinks; switch (upstreamUsageKey) { case thisMock.upstreamUsageKey: return thisMock.response; case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response; @@ -688,8 +688,8 @@ export async function mockGetUnpaginatedEntityLinks( default: return []; } } -mockGetUnpaginatedEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; -mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ +mockGetEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; +mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ id: 875, upstreamContextTitle: 'CS problems 3', upstreamVersion: 10, @@ -703,10 +703,10 @@ mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map( created: '2025-02-08T14:07:05.588484Z', updated: '2025-02-08T14:07:05.588484Z', })); -mockGetUnpaginatedEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty'; -mockGetUnpaginatedEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[]; +mockGetEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty'; +mockGetEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[]; -mockGetUnpaginatedEntityLinks.applyMock = () => jest.spyOn( +mockGetEntityLinks.applyMock = () => jest.spyOn( courseLibApi, - 'getUnpaginatedEntityLinks', -).mockImplementation(mockGetUnpaginatedEntityLinks); + 'getEntityLinks', +).mockImplementation(mockGetEntityLinks); From 3d6e221f997e5657a93b1fa7d95cfa86bece525b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Tue, 13 May 2025 12:53:32 -0500 Subject: [PATCH 09/37] fix: Issue with read-only units in libraries & published version of units in library units picker (#1940) Fixes the issues from https://github.com/openedx/frontend-app-authoring/issues/1633#issuecomment-2828953801 * In successfully added units, the "add new component" widget appears sometimes * In the "add existing unit" modal, the preview shows draft versions of units --- src/course-unit/data/api.js | 6 ++++-- src/course-unit/data/thunk.js | 3 +-- src/library-authoring/components/ContainerCard.tsx | 4 +++- src/library-authoring/containers/UnitInfo.test.tsx | 12 ++++++++++-- src/library-authoring/data/api.mocks.ts | 9 +++++++++ src/library-authoring/data/api.ts | 12 +++++++++--- src/library-authoring/data/apiHooks.ts | 4 ++-- src/library-authoring/units/LibraryUnitBlocks.tsx | 11 +++++++---- 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 7a46974060..58aa284264 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -3,7 +3,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { PUBLISH_TYPES } from '../constants'; -import { normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils'; +import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils'; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -24,7 +24,9 @@ export async function getCourseUnitData(unitId) { const { data } = await getAuthenticatedHttpClient() .get(getCourseUnitApiUrl(unitId)); - return camelCaseObject(data); + const result = camelCaseObject(data); + result.readOnly = isUnitReadOnly(result); + return result; } /** diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index ee2c4da655..481b9c6ca8 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -38,7 +38,7 @@ import { updateCourseOutlineInfoLoadingStatus, updateMovedXBlockParams, } from './slice'; -import { getNotificationMessage, isUnitReadOnly } from './utils'; +import { getNotificationMessage } from './utils'; export function fetchCourseUnitQuery(courseId) { return async (dispatch) => { @@ -46,7 +46,6 @@ export function fetchCourseUnitQuery(courseId) { try { const courseUnit = await getCourseUnitData(courseId); - courseUnit.readOnly = isUnitReadOnly(courseUnit); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL })); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 1617c9d1fb..a6442b1b54 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -208,7 +208,9 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { if (itemType === 'unit') { openUnitInfoSidebar(unitId); setUnitId(unitId); - navigateTo({ unitId }); + if (!componentPickerMode) { + navigateTo({ unitId }); + } } }, [unitId, itemType, openUnitInfoSidebar, navigateTo]); diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx index 677063f851..9b154d13c3 100644 --- a/src/library-authoring/containers/UnitInfo.test.tsx +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -5,7 +5,7 @@ import { initializeMocks, render as baseRender, screen, waitFor, fireEvent, } from '../../testUtils'; -import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks'; +import { mockContentLibrary, mockGetContainerChildren, mockGetContainerMetadata } from '../data/api.mocks'; import { LibraryProvider } from '../common/context/LibraryContext'; import UnitInfo from './UnitInfo'; import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api'; @@ -14,14 +14,16 @@ import { SidebarBodyComponentId, SidebarProvider } from '../common/context/Sideb mockGetContainerMetadata.applyMock(); mockContentLibrary.applyMock(); mockGetContainerMetadata.applyMock(); +mockGetContainerChildren.applyMock(); const { libraryId } = mockContentLibrary; const { containerId } = mockGetContainerMetadata; -const render = () => baseRender(, { +const render = (showOnlyPublished: boolean = false) => baseRender(, { extraWrapper: ({ children }) => ( ', () => { }); expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes'); }); + + it('show only published content', async () => { + render(true); + expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /text block published 1/i })).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index e267b9483a..3ff35e624d 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -188,6 +188,7 @@ mockCreateLibraryBlock.newHtmlData = { id: 'lb:Axim:TEST:html:123', blockType: 'html', displayName: 'New Text Component', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -202,6 +203,7 @@ mockCreateLibraryBlock.newProblemData = { id: 'lb:Axim:TEST:problem:prob1', blockType: 'problem', displayName: 'New Problem', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -216,6 +218,7 @@ mockCreateLibraryBlock.newVideoData = { id: 'lb:Axim:TEST:video:vid1', blockType: 'video', displayName: 'New Video', + publishedDisplayName: null, hasUnpublishedChanges: true, lastPublished: null, // or e.g. '2024-08-30T16:37:42Z', publishedBy: null, // or e.g. 'test_author', @@ -348,6 +351,7 @@ mockLibraryBlockMetadata.dataNeverPublished = { id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1', blockType: 'html', displayName: 'Introduction to Testing 1', + publishedDisplayName: null, lastPublished: null, publishedBy: null, lastDraftCreated: null, @@ -363,6 +367,7 @@ mockLibraryBlockMetadata.dataPublished = { id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: 'Introduction to Testing 2', lastPublished: '2024-06-22T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -391,6 +396,7 @@ mockLibraryBlockMetadata.dataWithCollections = { id: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: null, lastPublished: '2024-06-21T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -407,6 +413,7 @@ mockLibraryBlockMetadata.dataPublishedWithChanges = { id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fvv', blockType: 'html', displayName: 'Introduction to Testing 2', + publishedDisplayName: 'Introduction to Testing 3', lastPublished: '2024-06-22T00:00:00', publishedBy: 'Luke', lastDraftCreated: null, @@ -536,6 +543,7 @@ export async function mockGetContainerChildren(containerId: string): Promise `${getL /** * Get the URL for a single container children api. */ -export const getLibraryContainerChildrenApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}children/`; +export const getLibraryContainerChildrenApiUrl = (containerId: string, published: boolean = false) => `${getLibraryContainerApiUrl(containerId)}children/?published=${published}`; /** * Get the URL for library container collections. */ @@ -250,6 +250,7 @@ export interface LibraryBlockMetadata { id: string; blockType: string; displayName: string; + publishedDisplayName: string | null; lastPublished: string | null; publishedBy: string | null; lastDraftCreated: string | null; @@ -652,8 +653,13 @@ export async function restoreContainer(containerId: string) { /** * Fetch a library container's children's metadata. */ -export async function getLibraryContainerChildren(containerId: string): Promise { - const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerChildrenApiUrl(containerId)); +export async function getLibraryContainerChildren( + containerId: string, + published: boolean = false, +): Promise { + const { data } = await getAuthenticatedHttpClient().get( + getLibraryContainerChildrenApiUrl(containerId, published), + ); return camelCaseObject(data); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index a74fc5097e..01dd97c3eb 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -641,11 +641,11 @@ export const useRestoreContainer = (containerId: string) => { /** * Get the metadata and children for a container in a library */ -export const useContainerChildren = (containerId?: string) => ( +export const useContainerChildren = (containerId?: string, published: boolean = false) => ( useQuery({ enabled: !!containerId, queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!), - queryFn: () => api.getLibraryContainerChildren(containerId!), + queryFn: () => api.getLibraryContainerChildren(containerId!, published), structuralSharing: (oldData: api.LibraryBlockMetadata[], newData: api.LibraryBlockMetadata[]) => { // This just sets `isNew` flag to new children components if (oldData) { diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index f518e976d7..4d42bf2a45 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -56,6 +56,7 @@ interface ComponentBlockProps { /** Component header */ const BlockHeader = ({ block }: ComponentBlockProps) => { const intl = useIntl(); + const { showOnlyPublished } = useLibraryContext(); const { showToast } = useContext(ToastContext); const { navigateTo } = useLibraryRoutes(); const { openComponentInfoSidebar, setSidebarAction } = useSidebarContext(); @@ -101,7 +102,7 @@ const BlockHeader = ({ block }: ComponentBlockProps) => {
@@ -112,7 +113,7 @@ const BlockHeader = ({ block }: ComponentBlockProps) => { /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ onClick={(e) => e.stopPropagation()} > - {block.hasUnpublishedChanges && ( + {!showOnlyPublished && block.hasUnpublishedChanges && ( { const [hidePreviewFor, setHidePreviewFor] = useState(null); const { showToast } = useContext(ToastContext); - const { unitId, readOnly } = useLibraryContext(); + const { readOnly, showOnlyPublished } = useLibraryContext(); + const { sidebarComponentInfo } = useSidebarContext(); + const unitId = sidebarComponentInfo?.id; const { openAddContentSidebar } = useSidebarContext(); @@ -247,7 +250,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { isLoading, isError, error, - } = useContainerChildren(unitId); + } = useContainerChildren(unitId, showOnlyPublished); const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => { if (!newOrder) { From 1919eb4845a86d3c47182fe476820d2354c6dbdc Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 14 May 2025 18:15:24 +0000 Subject: [PATCH 10/37] fix: search modal refresh on typing (#1938) (#1948) --- src/course-outline/CourseOutline.test.jsx | 14 +++++++------- .../subsection-card/SubsectionCard.jsx | 11 ++++++++--- .../subsection-card/SubsectionCard.test.jsx | 5 +++-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index b0959c8218..cfd0191912 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -97,12 +97,6 @@ jest.mock('./data/api', () => ({ getTagsCount: () => jest.fn().mockResolvedValue({}), })); -jest.mock('../studio-home/hooks', () => ({ - useStudioHome: () => ({ - librariesV2Enabled: true, - }), -})); - // Mock ComponentPicker to call onComponentSelected on click jest.mock('../library-authoring/component-picker', () => ({ ComponentPicker: (props) => { @@ -160,7 +154,9 @@ describe('', () => { pathname: mockPathname, }); - store = initializeStore(); + store = initializeStore({ + studioHome: { studioHomeData: { librariesV2Enabled: true } }, + }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) @@ -179,6 +175,10 @@ describe('', () => { await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('render CourseOutline component correctly', async () => { const { getByText } = render(); diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 5430ef5d01..95f0b295e2 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -3,7 +3,7 @@ import React, { useContext, useEffect, useState, useRef, useCallback, } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, StandardModal, useToggle } from '@openedx/paragon'; @@ -25,8 +25,8 @@ import messages from './messages'; import { ComponentPicker } from '../../library-authoring'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { ContainerType } from '../../generic/key-utils'; -import { useStudioHome } from '../../studio-home/hooks'; import { ContentType } from '../../library-authoring/routes'; +import { getStudioHomeData } from '../../studio-home/data/selectors'; const SubsectionCard = ({ section, @@ -57,7 +57,12 @@ const SubsectionCard = ({ const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - const { librariesV2Enabled } = useStudioHome(); + // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, + // as it has a useEffect that fetches course waffle flags whenever + // location.search is updated. Course search updates location.search when + // user types, which will then trigger the useEffect and reload the page. + // See https://github.com/openedx/frontend-app-authoring/pull/1938. + const { librariesV2Enabled } = useSelector(getStudioHomeData); const [ isAddLibraryUnitModalOpen, openAddLibraryUnitModal, diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.jsx index ba15189381..59bfa08e54 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.jsx @@ -24,8 +24,9 @@ jest.mock('react-router-dom', () => ({ }), })); -jest.mock('../../studio-home/hooks', () => ({ - useStudioHome: () => ({ +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: () => ({ librariesV2Enabled: true, }), })); From 403dfa1e6b9710e90f1f34c10303bbc9936ce807 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 21 May 2025 22:20:16 +0000 Subject: [PATCH 11/37] [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 08ac1c0c4d391cc1d069638a497961794f711b5a) * fix: search text flickering (#1999) Fix flickering issue in search field. (cherry picked from commit 6f3b7ab962c1eabfe6ba542d51afa7d1dd76ca0c) * feat: open collection or unit page on double click only (#2002) Opens collection or unit page only on double click. (cherry picked from commit 503642be8c8783b60c6755a7870eff378afe0de2) --- .../LibraryAuthoringPage.test.tsx | 2 +- .../collections/CollectionInfo.tsx | 3 +- .../LibraryCollectionPage.test.tsx | 2 +- src/library-authoring/components/BaseCard.tsx | 2 +- .../components/CollectionCard.tsx | 10 +++-- .../components/ComponentCard.test.tsx | 29 +++++++++++++++ .../components/ComponentMenu.tsx | 8 +++- .../components/ContainerCard.tsx | 4 +- .../containers/UnitInfo.test.tsx | 37 +++++++++++-------- src/library-authoring/data/api.test.ts | 9 +++++ src/library-authoring/routes.ts | 10 +++-- .../units/LibraryUnitBlocks.tsx | 4 +- src/search-manager/SearchKeywordsField.tsx | 8 +++- 13 files changed, 94 insertions(+), 34 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index cded3d3340..f0c0404c90 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -370,7 +370,7 @@ describe('', () => { 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":[]'), diff --git a/src/library-authoring/collections/CollectionInfo.tsx b/src/library-authoring/collections/CollectionInfo.tsx index 277a6fc1c6..68d2dcb075 100644 --- a/src/library-authoring/collections/CollectionInfo.tsx +++ b/src/library-authoring/collections/CollectionInfo.tsx @@ -48,7 +48,8 @@ const CollectionInfo = () => { if (componentPickerMode) { setCollectionId(collectionId); } else { - navigateTo({ collectionId }); + /* istanbul ignore next */ + navigateTo({ collectionId, doubleClicked: true }); } }, [componentPickerMode, navigateTo]); diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index 3c3bf45474..eb102d2ed4 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -315,7 +315,7 @@ describe('', () => { 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":[]'), diff --git a/src/library-authoring/components/BaseCard.tsx b/src/library-authoring/components/BaseCard.tsx index 591fc3be6a..ef7dc5828c 100644 --- a/src/library-authoring/components/BaseCard.tsx +++ b/src/library-authoring/components/BaseCard.tsx @@ -22,7 +22,7 @@ type BaseCardProps = { tags: ContentHitTags; actions: React.ReactNode; hasUnpublishedChanges?: boolean; - onSelect: () => void; + onSelect: (e?: React.MouseEvent) => void; selected?: boolean; }; diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx index 1f158808f3..21d5ef5d4f 100644 --- a/src/library-authoring/components/CollectionCard.tsx +++ b/src/library-authoring/components/CollectionCard.tsx @@ -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]); diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index 087b0ad20a..e0e2c44e20 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -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(, { + path: '/library/:libraryId', + params: { libraryId }, extraWrapper: ({ children }) => ( { children } @@ -104,4 +113,24 @@ describe('', () => { 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: '', + }); + }); }); diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index e3d0afe64f..ba37117119 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -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" /> - openComponentEditor(usageKey) } : { disabled: true })}> + diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index a6442b1b54..ebcbbbeaf3 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -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]); diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx index 9b154d13c3..c44d19604d 100644 --- a/src/library-authoring/containers/UnitInfo.test.tsx +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -19,23 +19,28 @@ mockGetContainerChildren.applyMock(); const { libraryId } = mockContentLibrary; const { containerId } = mockGetContainerMetadata; -const render = (showOnlyPublished: boolean = false) => baseRender(, { - extraWrapper: ({ children }) => ( - - { + const params: { libraryId: string, unitId?: string } = { libraryId, unitId: containerId }; + return baseRender(, { + path: '/library/:libraryId/:unitId?', + params, + extraWrapper: ({ children }) => ( + - {children} - - - ), -}); + + {children} + + + ), + }); +}; let axiosMock: MockAdapter; let mockShowToast; diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index f5882ee678..ece0773f4d 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -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); + }); }); diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 5103684561..4615f229d3 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -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 { diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 4d42bf2a45..ef7d62e1a7 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -238,9 +238,7 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { const [hidePreviewFor, setHidePreviewFor] = useState(null); const { showToast } = useContext(ToastContext); - const { readOnly, showOnlyPublished } = useLibraryContext(); - const { sidebarComponentInfo } = useSidebarContext(); - const unitId = sidebarComponentInfo?.id; + const { unitId, readOnly, showOnlyPublished } = useLibraryContext(); const { openAddContentSidebar } = useSidebarContext(); diff --git a/src/search-manager/SearchKeywordsField.tsx b/src/search-manager/SearchKeywordsField.tsx index 90c09fdd93..bbe3b67f82 100644 --- a/src/search-manager/SearchKeywordsField.tsx +++ b/src/search-manager/SearchKeywordsField.tsx @@ -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 ( setSearchKeywords('')} value={searchKeywords} className={props.className} From 976dfcaab72b98a42da1e778d44eeb18b63e93bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 21 May 2025 19:33:23 -0300 Subject: [PATCH 12/37] fix: change InplaceTextEditor style and add optimistic update (#1953) (#2014) * Optimistic update for renaming Components, Collections and Containers * Change the InplaceTextEditor to show the new text until the onSave promise resolves * Change the InplaceTextEditor style to: Always show the rename button --- .../InplaceTextEditor.test.tsx | 58 ++++++- src/generic/inplace-text-editor/index.tsx | 154 ++++++------------ .../collections/CollectionInfoHeader.tsx | 15 +- .../component-info/ComponentInfoHeader.tsx | 19 ++- .../containers/ContainerInfoHeader.tsx | 15 +- .../containers/UnitInfo.test.tsx | 2 +- src/library-authoring/data/apiHooks.ts | 30 +++- .../units/LibraryUnitBlocks.tsx | 18 +- .../units/LibraryUnitPage.test.tsx | 36 ++-- .../units/LibraryUnitPage.tsx | 14 +- 10 files changed, 193 insertions(+), 168 deletions(-) diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx index 8914ad6de5..c4dbc46191 100644 --- a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx +++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { fireEvent, render as baseRender, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render as baseRender, + screen, +} from '@testing-library/react'; import { InplaceTextEditor } from '.'; const mockOnSave = jest.fn(); @@ -24,8 +29,8 @@ describe('', () => { expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument(); }); - it('should render the edit button if alwaysShowEditButton is true', () => { - render(); + it('should render the edit button', () => { + render(); expect(screen.getByText('Test text')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); @@ -36,7 +41,10 @@ describe('', () => { const title = screen.getByText('Test text'); expect(title).toBeInTheDocument(); - fireEvent.click(title); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); const textBox = screen.getByRole('textbox'); @@ -52,7 +60,10 @@ describe('', () => { const title = screen.getByText('Test text'); expect(title).toBeInTheDocument(); - fireEvent.click(title); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); const textBox = screen.getByRole('textbox'); @@ -62,4 +73,41 @@ describe('', () => { expect(textBox).not.toBeInTheDocument(); expect(mockOnSave).not.toHaveBeenCalled(); }); + + it('should show the new text while processing and roolback in case of error', async () => { + let rejecter: (err: Error) => void; + const longMockOnSave = jest.fn().mockReturnValue( + new Promise((_resolve, reject) => { + rejecter = reject; + }), + ); + render(); + + const text = screen.getByText('Test text'); + expect(text).toBeInTheDocument(); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + + const textBox = screen.getByRole('textbox'); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(longMockOnSave).toHaveBeenCalledWith('New text'); + + // Show pending new text + const newText = screen.getByText('New text'); + expect(newText).toBeInTheDocument(); + + await act(async () => { rejecter(new Error('error')); }); + + // Remove pending new text on error + expect(newText).not.toBeInTheDocument(); + + // Show original text + expect(screen.getByText('Test text')).toBeInTheDocument(); + }); }); diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx index 8caecd550f..b1bbe531cc 100644 --- a/src/generic/inplace-text-editor/index.tsx +++ b/src/generic/inplace-text-editor/index.tsx @@ -1,14 +1,11 @@ import React, { useCallback, - useEffect, useState, - forwardRef, } from 'react'; import { Form, Icon, IconButton, - OverlayTrigger, Stack, } from '@openedx/paragon'; import { Edit } from '@openedx/paragon/icons'; @@ -16,33 +13,11 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -interface IconWrapperProps { - popper: any; - children: React.ReactNode; - [key: string]: any; -} - -const IconWrapper = forwardRef(({ popper, children, ...props }, ref) => { - useEffect(() => { - // This is a workaround to force the popper to update its position when - // the editor is opened. - // Ref: https://react-bootstrap.netlify.app/docs/components/overlays/#updating-position-dynamically - popper.scheduleUpdate(); - }, [popper, children]); - - return ( -
- {children} -
- ); -}); - interface InplaceTextEditorProps { text: string; - onSave: (newText: string) => void; + onSave: (newText: string) => Promise; readOnly?: boolean; textClassName?: string; - alwaysShowEditButton?: boolean; } export const InplaceTextEditor: React.FC = ({ @@ -50,18 +25,29 @@ export const InplaceTextEditor: React.FC = ({ onSave, readOnly = false, textClassName, - alwaysShowEditButton = false, }) => { const intl = useIntl(); const [inputIsActive, setIsActive] = useState(false); + const [pendingSaveText, setPendingSaveText] = useState(); // state with the new text while updating const handleOnChangeText = useCallback( - (event) => { - const newText = event.target.value; - if (newText && newText !== text) { - onSave(newText); - } + async (event: React.ChangeEvent | React.KeyboardEvent) => { + const inputText = event.currentTarget.value; setIsActive(false); + if (inputText && inputText !== text) { + // NOTE: While using react query for optimistic updates would be the best approach, + // it could not be possible in some cases. For that reason, we use the `pendingSaveText` state + // to show the new text while saving. + setPendingSaveText(inputText); + try { + await onSave(inputText); + } catch { + // don't propagate the exception + } finally { + // reset the pending save text + setPendingSaveText(undefined); + } + } }, [text], ); @@ -78,86 +64,44 @@ export const InplaceTextEditor: React.FC = ({ } }; - if (readOnly) { + // If we have the `pendingSaveText` state it means that we are in the process of saving the new text. + // In that case, we show the new text instead of the original in read-only mode as an optimistic update. + if (readOnly || pendingSaveText) { return ( - {text} + {pendingSaveText || text} ); } - if (alwaysShowEditButton) { - return ( - - {inputIsActive - ? ( - - ) - : ( - - {text} - - )} - - - ); - } - return ( - - - - )} + -
- {inputIsActive - ? ( - - ) - : ( - - {text} - - )} -
-
+ {inputIsActive + ? ( + + ) + : ( + + {text} + + )} + +
); }; diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index 8f476d35ef..b885d72da2 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -26,14 +26,16 @@ const CollectionInfoHeader = () => { const updateMutation = useUpdateCollection(libraryId, collectionId); const { showToast } = useContext(ToastContext); - const handleSaveTitle = (newTitle: string) => { - updateMutation.mutateAsync({ - title: newTitle, - }).then(() => { + const handleSaveTitle = async (newTitle: string) => { + try { + await updateMutation.mutateAsync({ + title: newTitle, + }); showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); - }); + throw err; + } }; if (!collection) { @@ -46,7 +48,6 @@ const CollectionInfoHeader = () => { text={collection.title} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index 0757c9775d..11b3256945 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -26,16 +26,18 @@ const ComponentInfoHeader = () => { const updateMutation = useUpdateXBlockFields(usageKey); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - metadata: { - display_name: newDisplayName, - }, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }); showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - }); + throw err; + } }; if (!xblockFields) { @@ -48,7 +50,6 @@ const ComponentInfoHeader = () => { text={xblockFields?.displayName} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index 39d590db6c..65357c0f14 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -25,14 +25,16 @@ const ContainerInfoHeader = () => { const updateMutation = useUpdateContainer(containerId); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - displayName: newDisplayName, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - }); + throw err; + } }; if (!container) { @@ -45,7 +47,6 @@ const ContainerInfoHeader = () => { text={container.displayName} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx index c44d19604d..d6b019d4eb 100644 --- a/src/library-authoring/containers/UnitInfo.test.tsx +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -106,6 +106,6 @@ describe('', () => { it('show only published content', async () => { render(true); expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /text block published 1/i })).toBeInTheDocument(); + expect(screen.getByText(/text block published 1/i)).toBeInTheDocument(); }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 01dd97c3eb..65b9445ad1 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -472,15 +472,28 @@ export const useCollection = (libraryId: string, collectionId: string) => ( */ export const useUpdateCollection = (libraryId: string, collectionId: string) => { const queryClient = useQueryClient(); + const collectionQueryKey = libraryAuthoringQueryKeys.collection(libraryId, collectionId); return useMutation({ mutationFn: (data: api.UpdateCollectionComponentsRequest) => ( api.updateCollectionMetadata(libraryId, collectionId, data) ), + onMutate: (data) => { + const previousData = queryClient.getQueryData(collectionQueryKey) as api.CollectionMetadata; + queryClient.setQueryData(collectionQueryKey, { + ...previousData, + ...data, + }); + + return { previousData }; + }, + onError: (_err, _data, context) => { + queryClient.setQueryData(collectionQueryKey, context?.previousData); + }, onSettled: () => { // NOTE: We invalidate the library query here because we need to update the library's // collection list. queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId) }); + queryClient.invalidateQueries({ queryKey: collectionQueryKey }); }, }); }; @@ -598,13 +611,26 @@ export const useContainer = (containerId?: string) => ( export const useUpdateContainer = (containerId: string) => { const libraryId = getLibraryId(containerId); const queryClient = useQueryClient(); + const containerQueryKey = libraryAuthoringQueryKeys.container(containerId); return useMutation({ mutationFn: (data: api.UpdateContainerDataRequest) => api.updateContainerMetadata(containerId, data), + onMutate: (data) => { + const previousData = queryClient.getQueryData(containerQueryKey) as api.CollectionMetadata; + queryClient.setQueryData(containerQueryKey, { + ...previousData, + ...data, + }); + + return { previousData }; + }, + onError: (_err, _data, context) => { + queryClient.setQueryData(containerQueryKey, context?.previousData); + }, onSettled: () => { // NOTE: We invalidate the library query here because we need to update the library's // container list. queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); - queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) }); + queryClient.invalidateQueries({ queryKey: containerQueryKey }); }, }); }; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index ef7d62e1a7..8486e275b3 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -63,16 +63,18 @@ const BlockHeader = ({ block }: ComponentBlockProps) => { const updateMutation = useUpdateXBlockFields(block.originalId); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - metadata: { - display_name: newDisplayName, - }, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }); showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateComponentErrorMsg)); - }); + throw err; + } }; /* istanbul ignore next */ diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index 6d4ea7e31c..18671ca562 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -106,12 +106,12 @@ describe('', () => { it('can rename unit', async () => { renderLibraryUnitPage(); expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - // Unit title - const unitTitle = screen.getAllByRole( + + const editUnitTitleButton = screen.getAllByRole( 'button', - { name: mockGetContainerMetadata.containerData.displayName }, - )[0]; - fireEvent.click(unitTitle); + { name: /edit/i }, + )[0]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editUnitTitleButton); const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); axiosMock.onPatch(url).reply(200); @@ -137,12 +137,12 @@ describe('', () => { it('show error if renaming unit fails', async () => { renderLibraryUnitPage(); expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); - // Unit title - const unitTitle = screen.getAllByRole( + + const editUnitTitleButton = screen.getAllByRole( 'button', - { name: mockGetContainerMetadata.containerData.displayName }, - )[0]; - fireEvent.click(unitTitle); + { name: /edit/i }, + )[0]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editUnitTitleButton); const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); axiosMock.onPatch(url).reply(400); @@ -210,11 +210,11 @@ describe('', () => { // Wait loading of the component await screen.findByText('text block 0'); - const componentTitle = screen.getAllByRole( + const editButton = screen.getAllByRole( 'button', - { name: 'text block 0' }, - )[0]; - fireEvent.click(componentTitle); + { name: /edit/i }, + )[1]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editButton); await waitFor(() => { expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); @@ -244,11 +244,11 @@ describe('', () => { // Wait loading of the component await screen.findByText('text block 0'); - const componentTitle = screen.getAllByRole( + const editButton = screen.getAllByRole( 'button', - { name: 'text block 0' }, - )[0]; - fireEvent.click(componentTitle); + { name: /edit/i }, + )[1]; // 0 is the Unit Title, 1 is the first component on the list + fireEvent.click(editButton); await waitFor(() => { expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index a103a98966..c8d337314b 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -41,14 +41,16 @@ const EditableTitle = ({ unitId }: EditableTitleProps) => { const updateMutation = useUpdateContainer(unitId); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = (newDisplayName: string) => { - updateMutation.mutateAsync({ - displayName: newDisplayName, - }).then(() => { + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - }).catch(() => { + } catch (err) { showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - }); + throw err; + } }; // istanbul ignore if: this should never happen From dd731a0d19ae4d314e30dadef2cda8f36a2509cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 21 May 2025 20:18:26 -0300 Subject: [PATCH 13/37] fix: rename library publish button (#2015) --- src/library-authoring/generic/status-widget/index.tsx | 4 +++- src/library-authoring/library-info/LibraryPublishStatus.tsx | 1 + src/library-authoring/library-info/messages.ts | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/generic/status-widget/index.tsx b/src/library-authoring/generic/status-widget/index.tsx index 19fdc71444..3694e2836c 100644 --- a/src/library-authoring/generic/status-widget/index.tsx +++ b/src/library-authoring/generic/status-widget/index.tsx @@ -85,6 +85,7 @@ type StatusWidgedProps = { publishedBy: string | null; numBlocks?: number; onCommit?: () => void; + onCommitLabel?: string; onRevert?: () => void; }; @@ -114,6 +115,7 @@ const StatusWidget = ({ publishedBy, numBlocks, onCommit, + onCommitLabel, onRevert, }: StatusWidgedProps) => { const intl = useIntl(); @@ -188,7 +190,7 @@ const StatusWidget = ({ {onCommit && ( )} {onRevert && ( diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index 89ac4e8468..b6ee64738e 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -51,6 +51,7 @@ const LibraryPublishStatus = () => { Date: Thu, 22 May 2025 11:04:35 -0400 Subject: [PATCH 14/37] fix: do open editor of new xblock when duplicating (#2017) * feat: display editors as modals (#1838) * fix: do open editor of new xblock when duplicating (#1887) Fixes bug where after duplicating an xblock, the editor modal of the old xblock is being open instead of the new copied xblock. --- src/course-unit/CourseUnit.test.jsx | 682 +++++++++--------- .../add-component/AddComponent.jsx | 86 ++- src/course-unit/add-component/messages.js | 10 + .../xblock-container-iframe/hooks/types.ts | 5 +- .../hooks/useMessageHandlers.tsx | 16 +- .../xblock-container-iframe/index.tsx | 83 ++- .../xblock-container-iframe/messages.ts | 9 + src/data/slice.js | 1 + src/editors/Editor.tsx | 22 +- src/editors/EditorContext.tsx | 9 - src/editors/EditorPage.test.tsx | 17 - src/editors/EditorPage.tsx | 4 +- src/editors/VideoSelector.jsx | 6 +- src/editors/VideoSelectorPage.jsx | 6 + .../containers/EditorContainer/index.test.tsx | 1 - .../containers/EditorContainer/index.tsx | 28 +- .../components/SelectTypeModal/index.test.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 1 + .../components/VideoEditorModal.tsx | 9 +- .../components/VideoSettingsModal/index.tsx | 4 +- src/editors/containers/VideoEditor/index.tsx | 2 +- src/editors/containers/VideoGallery/hooks.js | 18 +- src/editors/containers/VideoGallery/index.jsx | 65 +- .../containers/VideoGallery/index.test.jsx | 38 +- .../containers/VideoGallery/messages.js | 11 +- .../VideoUploadEditor/VideoUploader.jsx | 18 +- .../containers/VideoUploadEditor/hooks.js | 11 +- .../containers/VideoUploadEditor/index.jsx | 10 +- .../components/ComponentEditorModal.tsx | 1 - 29 files changed, 709 insertions(+), 466 deletions(-) diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 419bd46987..e168ec09e9 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -65,6 +65,7 @@ import xblockContainerIframeMessages from './xblock-container-iframe/messages'; import headerNavigationsMessages from './header-navigations/messages'; import sidebarMessages from './sidebar/messages'; import messages from './messages'; +import * as selectors from '../data/selectors'; let axiosMock; let store; @@ -166,27 +167,27 @@ describe('', () => { }); it('render CourseUnit component correctly', async () => { - const { getByText, getByRole, getByTestId } = render(); + render(); const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; await waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); }); }); it('renders the course unit iframe with correct attributes', async () => { - const { getByTitle } = render(); + render(); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); expect(iframe).toHaveAttribute('style', 'height: 0px;'); @@ -210,27 +211,27 @@ describe('', () => { }); it('displays an error alert when a studioAjaxError message is received', async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.studioAjaxError, { error: 'Some error text...', }); }); - expect(getByTestId('saving-error-alert')).toBeInTheDocument(); + expect(screen.getByTestId('saving-error-alert')).toBeInTheDocument(); }); it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => { - const { getByTitle } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId }); - const legacyXBlockEditModalIframe = getByTitle( + const legacyXBlockEditModalIframe = screen.getByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).toBeInTheDocument(); @@ -248,14 +249,14 @@ describe('', () => { }); it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => { - const { getByTitle, queryByTitle } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId }); - const legacyXBlockEditModalIframe = queryByTitle( + const legacyXBlockEditModalIframe = screen.queryByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); @@ -263,14 +264,14 @@ describe('', () => { }); it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => { - const { getByTitle, queryByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.saveEditedXBlockData); - const legacyXBlockEditModalIframe = queryByTitle( + const legacyXBlockEditModalIframe = screen.queryByTitle( xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, ); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); @@ -285,7 +286,7 @@ describe('', () => { }); await waitFor(() => { - const courseUnitSidebar = getByTestId('course-unit-sidebar'); + const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); expect( within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), ).toBeInTheDocument(); @@ -304,10 +305,10 @@ describe('', () => { }); it('updates course unit sidebar after receiving refreshPositions message', async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.refreshPositions); }); @@ -321,7 +322,7 @@ describe('', () => { }); await waitFor(() => { - const courseUnitSidebar = getByTestId('course-unit-sidebar'); + const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); expect( within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), ).toBeInTheDocument(); @@ -340,12 +341,10 @@ describe('', () => { }); it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { - const { - getByTitle, getByText, queryByRole, getByRole, - } = render(); + render(); await waitFor(async () => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -356,10 +355,10 @@ describe('', () => { usageId: courseVerticalChildrenMock.children[0].block_id, }); - expect(getByText(/Delete this component?/i)).toBeInTheDocument(); - expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); + expect(screen.getByText(/Delete this component?/i)).toBeInTheDocument(); + expect(screen.getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); - const dialog = getByRole('dialog'); + const dialog = screen.getByRole('dialog'); expect(dialog).toBeInTheDocument(); // Find the Cancel and Delete buttons within the iframe by their specific classes @@ -372,7 +371,7 @@ describe('', () => { usageId: courseVerticalChildrenMock.children[0].block_id, }); - expect(getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); userEvent.click(deleteButton); }); @@ -393,14 +392,14 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); axiosMock @@ -431,28 +430,28 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage .replace('{xblockCount}', updatedCourseVerticalChildren.length), ); // after removing the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -460,9 +459,7 @@ describe('', () => { }); it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { - const { - getByTitle, getByRole, getByText, queryByRole, - } = render(); + render(); simulatePostMessageEvent(messageTypes.duplicateXBlock, { id: courseVerticalChildrenMock.children[0].block_id, @@ -482,8 +479,14 @@ describe('', () => { const updatedCourseVerticalChildren = [ ...courseVerticalChildrenMock.children, { - ...courseVerticalChildrenMock.children[0], name: 'New Cloned XBlock', + block_id: '1234567890', + block_type: 'drag-and-drop-v2', + user_partition_info: { + selectable_partitions: [], + selected_partition_index: -1, + selected_groups_label: '', + }, }, ]; @@ -495,9 +498,9 @@ describe('', () => { }); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -526,14 +529,14 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); }); axiosMock @@ -542,7 +545,7 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -550,21 +553,21 @@ describe('', () => { ); // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -574,19 +577,19 @@ describe('', () => { it('handles CourseUnit header action buttons', async () => { const { open } = window; window.open = jest.fn(); - const { getByRole } = render(); + render(); const { draft_preview_link: draftPreviewLink, published_preview_link: publishedPreviewLink, } = courseSectionVerticalMock; await waitFor(() => { - const viewLiveButton = getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); + const viewLiveButton = screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); userEvent.click(viewLiveButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); - const previewButton = getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); + const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); userEvent.click(previewButton); expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); @@ -596,12 +599,7 @@ describe('', () => { }); it('checks courseUnit title changing when edit query is successfully', async () => { - const { - findByText, - queryByRole, - getByRole, - getByTestId, - } = render(); + render(); let editTitleButton = null; let titleEditField = null; const newDisplayName = `${unitDisplayName} new`; @@ -637,7 +635,7 @@ describe('', () => { }); await waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); editTitleButton = within(unitHeaderTitle) .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); titleEditField = within(unitHeaderTitle) @@ -645,7 +643,7 @@ describe('', () => { }); expect(titleEditField).not.toBeInTheDocument(); userEvent.click(editTitleButton); - titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); await userEvent.clear(titleEditField); await userEvent.type(titleEditField, newDisplayName); @@ -653,9 +651,10 @@ describe('', () => { expect(titleEditField).toHaveValue(newDisplayName); - titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); + titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); expect(titleEditField).not.toBeInTheDocument(); - expect(await findByText(newDisplayName)).toBeInTheDocument(); + expect(await screen.findByText(newDisplayName)).toBeInTheDocument(); }); it('doesn\'t handle creating xblock and displays an error message', async () => { @@ -675,15 +674,14 @@ describe('', () => { }); }); - it('handle creating Problem xblock and navigate to editor page', async () => { - const { courseKey, locator } = courseCreateXblockMock; + it('handle creating Problem xblock and showing editor modal', async () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) .reply(200, courseCreateXblockMock); - const { getByText, getByRole } = render(); + render(); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); }); axiosMock @@ -703,13 +701,18 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await waitFor(() => { - const problemButton = getByRole('button', { + const problemButton = screen.getByRole('button', { name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), + hidden: true, }); userEvent.click(problemButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/problem/${locator}`); + }); + + await waitFor(() => { + expect(screen.getByRole('heading', { + name: new RegExp(`${addComponentMessages.blockEditorModalTitle.defaultMessage}`, 'i'), + })).toBeInTheDocument(); }); axiosMock @@ -719,66 +722,28 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating problem xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); }); - it('handle creating Text xblock and saves scroll position in localStorage', async () => { - const { getByText, getByRole } = render(); - const xblockType = 'text'; - - axiosMock - .onPost(postXBlockBaseApiUrl({ type: xblockType, category: 'html', parentLocator: blockId })) - .reply(200, courseCreateXblockMock); - - window.scrollTo(0, 250); - Object.defineProperty(window, 'scrollY', { value: 250, configurable: true }); - - await waitFor(() => { - const textButton = screen.getByRole('button', { name: /Text/i }); - - expect(getByText(addComponentMessages.title.defaultMessage)).toBeInTheDocument(); - - userEvent.click(textButton); - - const addXBlockDialog = getByRole('dialog'); - expect(addXBlockDialog).toBeInTheDocument(); - - expect(getByText( - addComponentMessages.modalContainerTitle.defaultMessage.replace('{componentTitle}', xblockType), - )).toBeInTheDocument(); - - const textRadio = screen.getByRole('radio', { name: /Text/i }); - userEvent.click(textRadio); - expect(textRadio).toBeChecked(); - - const selectBtn = getByRole('button', { name: addComponentMessages.modalBtnText.defaultMessage }); - expect(selectBtn).toBeInTheDocument(); - - userEvent.click(selectBtn); - }); - - expect(localStorage.getItem('createXBlockLastYPosition')).toBe('250'); - }); - it('correct addition of a new course unit after click on the "Add new unit" button', async () => { - const { getByRole, getAllByTestId } = render(); + render(); let units = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -788,7 +753,7 @@ describe('', () => { ]); await waitFor(async () => { - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; expect(units).toHaveLength(courseUnits.length); }); @@ -805,8 +770,8 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); - units = getAllByTestId('course-unit-btn'); + const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); + units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData .xblock_info.ancestor_info.ancestors[0].child_info.children; @@ -818,7 +783,7 @@ describe('', () => { }); it('the sequence unit is updated after changing the unit header', async () => { - const { getAllByTestId, getByTestId } = render(); + render(); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ @@ -850,7 +815,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - const unitHeaderTitle = getByTestId('unit-header-title'); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); userEvent.click(editTitleButton); @@ -862,20 +827,95 @@ describe('', () => { await userEvent.tab(); await waitFor(async () => { - const units = getAllByTestId('course-unit-btn'); + const units = screen.getAllByTestId('course-unit-btn'); expect(units.some(unit => unit.title === newDisplayName)).toBe(true); }); }); - it('handles creating Video xblock and navigates to editor page', async () => { - const { courseKey, locator } = courseCreateXblockMock; + it('handles creating Video xblock and showing editor modal using videogalleryflow', async () => { + const waffleSpy = jest.spyOn(selectors, 'getWaffleFlags').mockReturnValue({ useVideoGalleryFlow: true }); + + axiosMock + .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .reply(200, courseCreateXblockMock); + render(); + + await waitFor(() => { + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }); + + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + await waitFor(() => { + // check if the sidebar status is Published and Live + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + }); + + expect(screen.getByText( + sidebarMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseUnitIndexMock.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + + const videoButton = screen.getByRole('button', { + name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), + hidden: true, + }); + + userEvent.click(videoButton); + + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + + await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + + // after creating video xblock, the sidebar status changes to Draft (unpublished changes) + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( + sidebarMessages.publishInfoDraftSaved.defaultMessage + .replace('{editedOn}', courseUnitIndexMock.edited_on) + .replace('{editedBy}', courseUnitIndexMock.edited_by), + )).toBeInTheDocument(); + expect(screen.getByText( + sidebarMessages.releaseInfoWithSection.defaultMessage + .replace('{sectionName}', courseUnitIndexMock.release_date_from), + )).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); + waffleSpy.mockRestore(); + }); + + it('handles creating Video xblock and showing editor modal', async () => { axiosMock .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .reply(200, courseCreateXblockMock); - const { getByText, queryByRole, getByRole } = render(); + render(); await waitFor(() => { - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); }); axiosMock @@ -896,23 +936,28 @@ describe('', () => { await waitFor(() => { // check if the sidebar status is Published and Live - expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishLastPublished.defaultMessage .replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - const videoButton = getByRole('button', { + const videoButton = screen.getByRole('button', { name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), + hidden: true, }); userEvent.click(videoButton); - expect(mockedUsedNavigate).toHaveBeenCalled(); - expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); }); + /** TODO -- fix this test. + await waitFor(() => { + expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument(); + }); + */ + axiosMock .onGet(getCourseUnitApiUrl(blockId)) .reply(200, courseUnitIndexMock); @@ -920,45 +965,45 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); }); it('renders course unit details for a draft with unpublished changes', async () => { - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); + expect(screen.getByText( sidebarMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedBy}', courseUnitIndexMock.edited_by), )).toBeInTheDocument(); - expect(getByText( + expect(screen.getByText( sidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); @@ -966,14 +1011,14 @@ describe('', () => { }); it('renders course unit details in the sidebar', async () => { - const { getByText } = render(); + render(); const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id); await waitFor(() => { - expect(getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(courseUnitLocationId)).toBeInTheDocument(); - expect(getByText(sidebarMessages.unitLocationDescription.defaultMessage + expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(courseUnitLocationId)).toBeInTheDocument(); + expect(screen.getByText(sidebarMessages.unitLocationDescription.defaultMessage .replace('{id}', courseUnitLocationId))).toBeInTheDocument(); }); }); @@ -1011,13 +1056,13 @@ describe('', () => { }); it('should toggle visibility from sidebar and update course unit state accordingly', async () => { - const { getByRole, getByTestId } = render(); + render(); let courseUnitSidebar; let draftUnpublishedChangesHeading; let visibilityCheckbox; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); draftUnpublishedChangesHeading = within(courseUnitSidebar) .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); @@ -1054,7 +1099,7 @@ describe('', () => { userEvent.click(visibilityCheckbox); - const modalNotification = getByRole('dialog'); + const modalNotification = screen.getByRole('dialog'); const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage }); const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage }); const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' }); @@ -1086,12 +1131,12 @@ describe('', () => { }); it('should publish course unit after click on the "Publish" button', async () => { - const { getByTestId } = render(); + render(); let courseUnitSidebar; let publishBtn; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }); expect(publishBtn).toBeInTheDocument(); @@ -1125,12 +1170,12 @@ describe('', () => { }); it('should discard changes after click on the "Discard changes" button', async () => { - const { getByTestId, getByRole } = render(); + render(); let courseUnitSidebar; let discardChangesBtn; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); const draftUnpublishedChangesHeading = within(courseUnitSidebar) .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); @@ -1140,7 +1185,7 @@ describe('', () => { userEvent.click(discardChangesBtn); - const modalNotification = getByRole('dialog'); + const modalNotification = screen.getByRole('dialog'); expect(modalNotification).toBeInTheDocument(); expect(within(modalNotification) .getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument(); @@ -1177,7 +1222,7 @@ describe('', () => { }); it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { - const { getByRole, getByTestId } = render(); + render(); let courseUnitSidebar; let sidebarVisibilityCheckbox; let modalVisibilityCheckbox; @@ -1185,16 +1230,16 @@ describe('', () => { let restrictAccessSelect; await waitFor(() => { - courseUnitSidebar = getByTestId('course-unit-sidebar'); + courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); sidebarVisibilityCheckbox = within(courseUnitSidebar) .getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage); expect(sidebarVisibilityCheckbox).not.toBeChecked(); - const headerConfigureBtn = getByRole('button', { name: /settings/i }); + const headerConfigureBtn = screen.getByRole('button', { name: /settings/i }); expect(headerConfigureBtn).toBeInTheDocument(); userEvent.click(headerConfigureBtn); - configureModal = getByTestId('configure-modal'); + configureModal = screen.getByTestId('configure-modal'); restrictAccessSelect = within(configureModal) .getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); expect(within(configureModal) @@ -1250,8 +1295,8 @@ describe('', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'true', }); - const { getByText } = render(); - await waitFor(() => { expect(getByText('Unit tags')).toBeInTheDocument(); }); + render(); + await waitFor(() => { expect(screen.getByText('Unit tags')).toBeInTheDocument(); }); }); it('hides the Tags sidebar when not enabled', async () => { @@ -1259,15 +1304,13 @@ describe('', () => { ...getConfig(), ENABLE_TAGGING_TAXONOMY_PAGES: 'false', }); - const { queryByText } = render(); - await waitFor(() => { expect(queryByText('Unit tags')).not.toBeInTheDocument(); }); + render(); + await waitFor(() => { expect(screen.queryByText('Unit tags')).not.toBeInTheDocument(); }); }); describe('Copy paste functionality', () => { it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { - const { - getAllByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1279,8 +1322,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); let units = null; const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); @@ -1291,7 +1334,7 @@ describe('', () => { ]); await waitFor(() => { - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; expect(units).toHaveLength(courseUnits.length); }); @@ -1307,7 +1350,7 @@ describe('', () => { await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - units = getAllByTestId('course-unit-btn'); + units = screen.getAllByTestId('course-unit-btn'); const updatedCourseUnits = updatedCourseSectionVerticalData .xblock_info.ancestor_info.ancestors[0].child_info.children; @@ -1318,7 +1361,7 @@ describe('', () => { }); it('should increase the number of course XBlocks after copying and pasting a block', async () => { - const { getByRole, getByTitle } = render(); + render(); simulatePostMessageEvent(messageTypes.copyXBlock, { id: courseVerticalChildrenMock.children[0].block_id, @@ -1337,11 +1380,11 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: messages.pasteButtonText.defaultMessage })); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -1377,7 +1420,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toHaveAttribute( 'aria-label', xblockContainerIframeMessages.xblockIframeLabel.defaultMessage @@ -1387,9 +1430,7 @@ describe('', () => { }); it('displays a notification about new files after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1401,8 +1442,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1421,7 +1462,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const newFilesAlert = getByTestId('has-new-files-alert'); + const newFilesAlert = screen.getByTestId('has-new-files-alert'); expect(within(newFilesAlert) .getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument(); @@ -1435,13 +1476,11 @@ describe('', () => { userEvent.click(within(newFilesAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-new-files-alert')).toBeNull(); + expect(screen.queryByTestId('has-new-files-alert')).toBeNull(); }); it('displays a notification about conflicting errors after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1453,8 +1492,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1475,7 +1514,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert'); + const conflictingErrorsAlert = screen.getByTestId('has-conflicting-errors-alert'); expect(within(conflictingErrorsAlert) .getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument(); @@ -1489,13 +1528,11 @@ describe('', () => { userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-conflicting-errors-alert')).toBeNull(); + expect(screen.queryByTestId('has-conflicting-errors-alert')).toBeNull(); }); it('displays a notification about error files after pasting a component', async () => { - const { - queryByTestId, getByTestId, getByRole, - } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1507,8 +1544,8 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); - userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); - userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); + userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; @@ -1529,7 +1566,7 @@ describe('', () => { global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); - const errorFilesAlert = getByTestId('has-error-files-alert'); + const errorFilesAlert = screen.getByTestId('has-error-files-alert'); expect(within(errorFilesAlert) .getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument(); @@ -1538,11 +1575,11 @@ describe('', () => { userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i)); - expect(queryByTestId('has-error-files')).toBeNull(); + expect(screen.queryByTestId('has-error-files')).toBeNull(); }); it('should hide the "Paste component" block if canPasteComponent is false', async () => { - const { queryByText, queryByRole } = render(); + render(); axiosMock .onGet(getCourseVerticalChildrenApiUrl(blockId)) @@ -1553,10 +1590,10 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); - expect(queryByRole('button', { + expect(screen.queryByRole('button', { name: messages.pasteButtonText.defaultMessage, })).not.toBeInTheDocument(); - expect(queryByText( + expect(screen.queryByText( pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, )).not.toBeInTheDocument(); }); @@ -1590,9 +1627,7 @@ describe('', () => { }); it('should display "Move Modal" on receive trigger message', async () => { - const { - getByRole, - } = render(); + render(); await screen.findByText(unitDisplayName); @@ -1606,15 +1641,12 @@ describe('', () => { await screen.findByText( moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), ); - expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); }); it('should navigates to xBlock current unit', async () => { - const { - getByText, - getByRole, - } = render(); + render(); await screen.findByText(unitDisplayName); @@ -1630,7 +1662,7 @@ describe('', () => { ); const currentSection = courseOutlineInfoMock.child_info.children[1]; - const currentSectionItemBtn = getByRole('button', { + const currentSectionItemBtn = screen.getByRole('button', { name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSectionItemBtn).toBeInTheDocument(); @@ -1638,7 +1670,7 @@ describe('', () => { await waitFor(() => { const currentSubsection = currentSection.child_info.children[0]; - const currentSubsectionItemBtn = getByRole('button', { + const currentSubsectionItemBtn = screen.getByRole('button', { name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSubsectionItemBtn).toBeInTheDocument(); @@ -1646,7 +1678,7 @@ describe('', () => { }); await waitFor(() => { - const currentComponentLocationText = getByText( + const currentComponentLocationText = screen.getByText( moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage, ); expect(currentComponentLocationText).toBeInTheDocument(); @@ -1654,9 +1686,7 @@ describe('', () => { }); it('should allow move operation and handles it successfully', async () => { - const { - getByRole, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1680,7 +1710,7 @@ describe('', () => { ); const currentSection = courseOutlineInfoMock.child_info.children[1]; - const currentSectionItemBtn = getByRole('button', { + const currentSectionItemBtn = screen.getByRole('button', { name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSectionItemBtn).toBeInTheDocument(); @@ -1688,7 +1718,7 @@ describe('', () => { const currentSubsection = currentSection.child_info.children[1]; await waitFor(() => { - const currentSubsectionItemBtn = getByRole('button', { + const currentSubsectionItemBtn = screen.getByRole('button', { name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentSubsectionItemBtn).toBeInTheDocument(); @@ -1697,14 +1727,14 @@ describe('', () => { await waitFor(() => { const currentUnit = currentSubsection.child_info.children[0]; - const currentUnitItemBtn = getByRole('button', { + const currentUnitItemBtn = screen.getByRole('button', { name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, }); expect(currentUnitItemBtn).toBeInTheDocument(); userEvent.click(currentUnitItemBtn); }); - const moveModalBtn = getByRole('button', { + const moveModalBtn = screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage, }); expect(moveModalBtn).toBeInTheDocument(); @@ -1718,10 +1748,7 @@ describe('', () => { }); it('should display "Move Confirmation" alert after moving and undo operations', async () => { - const { - queryByRole, - getByText, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1738,18 +1765,18 @@ describe('', () => { simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator }); - const dismissButton = queryByRole('button', { + const dismissButton = screen.queryByRole('button', { name: /dismiss/i, hidden: true, }); - const undoButton = queryByRole('button', { + const undoButton = screen.queryByRole('button', { name: messages.undoMoveButton.defaultMessage, hidden: true, }); - const newLocationButton = queryByRole('button', { + const newLocationButton = screen.queryByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); - expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); + expect(screen.getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument(); expect(undoButton).toBeInTheDocument(); expect(newLocationButton).toBeInTheDocument(); @@ -1757,9 +1784,9 @@ describe('', () => { userEvent.click(undoButton); await waitFor(() => { - expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); }); - expect(getByText( + expect(screen.getByText( messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title), )).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument(); @@ -1768,9 +1795,7 @@ describe('', () => { }); it('should navigate to new location by button click', async () => { - const { - queryByRole, - } = render(); + render(); axiosMock .onPatch(postXBlockBaseApiUrl()) @@ -1785,7 +1810,7 @@ describe('', () => { callbackFn: requestData.callbackFn, }), store.dispatch); - const newLocationButton = queryByRole('button', { + const newLocationButton = screen.queryByRole('button', { name: messages.newLocationButton.defaultMessage, hidden: true, }); userEvent.click(newLocationButton); @@ -1798,16 +1823,14 @@ describe('', () => { describe('XBlock restrict access', () => { it('opens xblock restrict access modal successfully', async () => { - const { - getByTitle, getByTestId, - } = render(); + render(); const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const usageId = courseVerticalChildrenMock.children[0].block_id; expect(iframe).toBeInTheDocument(); @@ -1817,7 +1840,7 @@ describe('', () => { }); await waitFor(() => { - const configureModal = getByTestId('configure-modal'); + const configureModal = screen.getByTestId('configure-modal'); expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); @@ -1826,12 +1849,10 @@ describe('', () => { }); it('closes xblock restrict access modal when cancel button is clicked', async () => { - const { - getByTitle, queryByTestId, getByTestId, - } = render(); + render(); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.manageXBlockAccess, { usageId: courseVerticalChildrenMock.children[0].block_id, @@ -1839,7 +1860,7 @@ describe('', () => { }); await waitFor(() => { - const configureModal = getByTestId('configure-modal'); + const configureModal = screen.getByTestId('configure-modal'); expect(configureModal).toBeInTheDocument(); userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.cancelButton.defaultMessage, @@ -1847,7 +1868,7 @@ describe('', () => { expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); }); - expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); }); it('handles submit xblock restrict access data when save button is clicked', async () => { @@ -1858,15 +1879,13 @@ describe('', () => { }) .reply(200, { dummy: 'value' }); - const { - getByTitle, getByRole, getByTestId, queryByTestId, - } = render(); + render(); const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); }); @@ -1876,13 +1895,13 @@ describe('', () => { }); }); - const configureModal = await waitFor(() => getByTestId('configure-modal')); + const configureModal = await waitFor(() => screen.getByTestId('configure-modal')); expect(configureModal).toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); - const restrictAccessSelect = getByRole('combobox', { + const restrictAccessSelect = screen.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage, }); @@ -1912,17 +1931,17 @@ describe('', () => { expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id)); }); - expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); }); }); const checkLegacyEditModalOnEditMessage = async () => { - const { getByTitle, getByTestId } = render(); + render(); await waitFor(() => { - const editButton = getByTestId('header-edit-button'); + const editButton = screen.getByTestId('header-edit-button'); expect(editButton).toBeInTheDocument(); - const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblocksIframe).toBeInTheDocument(); userEvent.click(editButton); }); @@ -2109,46 +2128,65 @@ describe('', () => { }); it('should render split test content page correctly', async () => { - const { - getByText, - getByRole, - queryByRole, - getByTestId, - queryByText, - } = render(); + render(); const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components'; waitFor(() => { - const unitHeaderTitle = getByTestId('unit-header-title'); - expect(getByText(unitDisplayName)).toBeInTheDocument(); + const unitHeaderTitle = screen.getByTestId('unit-header-title'); + expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); - expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); - expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); - expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); // Sidebar const sidebarContent = [ - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage.replaceAll('{bold_tag}', '') }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage }, - { query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage }, - { query: queryByText, name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage }, - { query: queryByRole, type: 'link', name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }, + { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage }, + { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') }, + { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage }, + { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage + .replaceAll('{bold_tag}', ''), + }, + { + query: screen.queryByRole, + type: 'heading', + name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage, + }, + { + query: screen.queryByRole, + type: 'heading', + name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage, + }, + { + query: screen.queryByText, + name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage, + }, + { + query: screen.queryByRole, + type: 'link', + name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage, + }, ]; sidebarContent.forEach(({ query, type, name }) => { @@ -2156,7 +2194,7 @@ describe('', () => { }); expect( - queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }), + screen.queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }), ).toHaveAttribute('href', helpLinkUrl); }); }); @@ -2169,7 +2207,7 @@ describe('', () => { }); it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { - const { getByTitle } = render(); + render(); const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id; @@ -2196,7 +2234,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await waitFor(() => { - const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(iframe).toBeInTheDocument(); simulatePostMessageEvent(messageTypes.currentXBlockId, { id: targetBlockId, diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 3c44f743fb..9e0fd850f7 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -1,13 +1,14 @@ import { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, StandardModal, useToggle, } from '@openedx/paragon'; import { getCourseSectionVertical } from '../data/selectors'; +import { getWaffleFlags } from '../../data/selectors'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import ComponentModalView from './add-component-modals/ComponentModalView'; import AddComponentButton from './add-component-btn'; @@ -16,6 +17,8 @@ import { ComponentPicker } from '../../library-authoring/component-picker'; import { messageTypes } from '../constants'; import { useIframe } from '../../generic/hooks/context/hooks'; import { useEventListener } from '../../generic/hooks'; +import VideoSelectorPage from '../../editors/VideoSelectorPage'; +import EditorPage from '../../editors/EditorPage'; const AddComponent = ({ parentLocator, @@ -24,7 +27,6 @@ const AddComponent = ({ addComponentTemplateData, handleCreateNewCourseXBlock, }) => { - const navigate = useNavigate(); const intl = useIntl(); const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false); @@ -32,10 +34,17 @@ const AddComponent = ({ const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const blockId = addComponentTemplateData.parentLocator || parentLocator; const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); + const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); + + const [blockType, setBlockType] = useState(null); + const [courseId, setCourseId] = useState(null); + const [newBlockId, setNewBlockId] = useState(null); const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle(); const [selectedComponents, setSelectedComponents] = useState([]); const [usageId, setUsageId] = useState(null); const { sendMessageToIframe } = useIframe(); + const { useVideoGalleryFlow } = useSelector(getWaffleFlags); const receiveMessage = useCallback(({ data: { type, payload } }) => { if (type === messageTypes.showMultipleComponentPicker) { @@ -54,6 +63,12 @@ const AddComponent = ({ closeSelectLibraryContentModal(); }, [selectedComponents]); + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + const handleLibraryV2Selection = useCallback((selection) => { handleCreateNewCourseXBlock({ type: COMPONENT_TYPES.libraryV2, @@ -71,12 +86,28 @@ const AddComponent = ({ handleCreateNewCourseXBlock({ type, parentLocator: blockId }); break; case COMPONENT_TYPES.problem: - case COMPONENT_TYPES.video: handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { - localStorage.setItem('createXBlockLastYPosition', window.scrollY); - navigate(`/course/${courseKey}/editor/${type}/${locator}`); + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); }); break; + case COMPONENT_TYPES.video: + handleCreateNewCourseXBlock( + { type, parentLocator: blockId }, + /* istanbul ignore next */ ({ courseKey, locator }) => { + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + if (useVideoGalleryFlow) { + showVideoSelectorModal(); + } else { + showXBlockEditorModal(); + } + }, + ); + break; // TODO: The library functional will be a bit different of current legacy (CMS) // behaviour and this ticket is on hold (blocked by other development team). case COMPONENT_TYPES.library: @@ -99,9 +130,11 @@ const AddComponent = ({ type, boilerplate: moduleName, parentLocator: blockId, - }, ({ courseKey, locator }) => { - localStorage.setItem('createXBlockLastYPosition', window.scrollY); - navigate(`/course/${courseKey}/editor/html/${locator}`); + }, /* istanbul ignore next */ ({ courseKey, locator }) => { + setCourseId(courseKey); + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); }); break; default: @@ -201,6 +234,43 @@ const AddComponent = ({ onChangeComponentSelection={setSelectedComponents} /> + +
+ onXBlockSave} + /> +
+
+ +
+ onXBlockSave} + /> +
+
); } diff --git a/src/course-unit/add-component/messages.js b/src/course-unit/add-component/messages.js index 31c37238df..f737a3bd33 100644 --- a/src/course-unit/add-component/messages.js +++ b/src/course-unit/add-component/messages.js @@ -31,6 +31,16 @@ const messages = defineMessages({ defaultMessage: 'Add selected components', description: 'Problem bank component add button text.', }, + videoPickerModalTitle: { + id: 'course-authoring.course-unit.modal.video-title.text', + defaultMessage: 'Select video', + description: 'Video picker modal title.', + }, + blockEditorModalTitle: { + id: 'course-authoring.course-unit.modal.block-editor-title.text', + defaultMessage: 'Edit component', + description: 'Block editor modal title.', + }, modalContainerTitle: { id: 'course-authoring.course-unit.modal.container.title', defaultMessage: 'Add {componentTitle} component', diff --git a/src/course-unit/xblock-container-iframe/hooks/types.ts b/src/course-unit/xblock-container-iframe/hooks/types.ts index 4775673c1c..3c54a90f29 100644 --- a/src/course-unit/xblock-container-iframe/hooks/types.ts +++ b/src/course-unit/xblock-container-iframe/hooks/types.ts @@ -1,11 +1,11 @@ export type UseMessageHandlersTypes = { courseId: string; - navigate: (path: string) => void; dispatch: (action: any) => void; setIframeOffset: (height: number) => void; handleDeleteXBlock: (usageId: string) => void; handleScrollToXBlock: (scrollOffset: number) => void; - handleDuplicateXBlock: (blockType: string, usageId: string) => void; + handleDuplicateXBlock: (usageId: string) => void; + handleEditXBlock: (blockType: string, usageId: string) => void; handleManageXBlockAccess: (usageId: string) => void; handleShowLegacyEditXBlockModal: (id: string) => void; handleCloseLegacyEditorXBlockModal: () => void; @@ -14,7 +14,6 @@ export type UseMessageHandlersTypes = { handleOpenManageTagsModal: (id: string) => void; handleShowProcessingNotification: (variant: string) => void; handleHideProcessingNotification: () => void; - handleRedirectToXBlockEditPage: (payload: { type: string, locator: string }) => void; }; export type MessageHandlersTypes = Record void>; diff --git a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx index 075418a5ce..daae1b8223 100644 --- a/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx +++ b/src/course-unit/xblock-container-iframe/hooks/useMessageHandlers.tsx @@ -16,7 +16,6 @@ import { MessageHandlersTypes, UseMessageHandlersTypes } from './types'; */ export const useMessageHandlers = ({ courseId, - navigate, dispatch, setIframeOffset, handleDeleteXBlock, @@ -30,15 +29,15 @@ export const useMessageHandlers = ({ handleOpenManageTagsModal, handleShowProcessingNotification, handleHideProcessingNotification, - handleRedirectToXBlockEditPage, + handleEditXBlock, }: UseMessageHandlersTypes): MessageHandlersTypes => { const { copyToClipboard } = useClipboard(); return useMemo(() => ({ [messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId), [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), - [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), - [messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), + [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => handleEditXBlock(blockType, usageId), + [messageTypes.duplicateXBlock]: ({ usageId }) => handleDuplicateXBlock(usageId), [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), [messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000), [messageTypes.toggleCourseXBlockDropdown]: ({ @@ -52,9 +51,14 @@ export const useMessageHandlers = ({ [messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId), [messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding), [messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting), - [messageTypes.copyXBlockLegacy]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.copying), + [messageTypes.copyXBlockLegacy]: /* istanbul ignore next */ () => handleShowProcessingNotification( + NOTIFICATION_MESSAGES.copying, + ), [messageTypes.hideProcessingNotification]: handleHideProcessingNotification, - [messageTypes.handleRedirectToXBlockEditPage]: (payload) => handleRedirectToXBlockEditPage(payload), + [messageTypes.handleRedirectToXBlockEditPage]: /* istanbul ignore next */ (payload) => handleEditXBlock( + payload.type, + payload.locator, + ), }), [ courseId, handleDeleteXBlock, diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 9e95ee8829..0c1ab91e24 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -1,10 +1,10 @@ +import { getConfig } from '@edx/frontend-platform'; import { FC, useEffect, useState, useMemo, useCallback, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle, Sheet } from '@openedx/paragon'; -import { useDispatch } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; +import { useToggle, Sheet, StandardModal } from '@openedx/paragon'; +import { useDispatch, useSelector } from 'react-redux'; import { hideProcessingNotification, @@ -13,9 +13,9 @@ import { import DeleteModal from '../../generic/delete-modal/DeleteModal'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; import ModalIframe from '../../generic/modal-iframe'; +import { getWaffleFlags } from '../../data/selectors'; import { IFRAME_FEATURE_POLICY } from '../../constants'; import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; -import supportedEditors from '../../editors/supportedEditors'; import { useIframe } from '../../generic/hooks/context/hooks'; import { fetchCourseSectionVerticalData, @@ -35,16 +35,22 @@ import messages from './messages'; import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; import { useIframeContent } from '../../generic/hooks/useIframeContent'; import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; +import VideoSelectorPage from '../../editors/VideoSelectorPage'; +import EditorPage from '../../editors/EditorPage'; const XBlockContainerIframe: FC = ({ courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, }) => { const intl = useIntl(); const dispatch = useDispatch(); - const navigate = useNavigate(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); + const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); + const [blockType, setBlockType] = useState(''); + const { useVideoGalleryFlow } = useSelector(getWaffleFlags); + const [newBlockId, setNewBlockId] = useState(''); const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); const [iframeOffset, setIframeOffset] = useState(0); const [deleteXBlockId, setDeleteXBlockId] = useState(null); @@ -64,14 +70,27 @@ const XBlockContainerIframe: FC = ({ setIframeRef(iframeRef); }, [setIframeRef]); + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + sendMessageToIframe(messageTypes.refreshXBlock, null); + }, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]); + + const handleEditXBlock = useCallback((type: string, id: string) => { + setBlockType(type); + setNewBlockId(id); + if (type === 'video' && useVideoGalleryFlow) { + showVideoSelectorModal(); + } else { + showXBlockEditorModal(); + } + }, [showVideoSelectorModal, showXBlockEditorModal]); + const handleDuplicateXBlock = useCallback( - (blockType: string, usageId: string) => { + (usageId: string) => { unitXBlockActions.handleDuplicate(usageId); - if (supportedEditors[blockType]) { - navigate(`/course/${courseId}/editor/${blockType}/${usageId}`); - } }, - [unitXBlockActions, courseId, navigate], + [unitXBlockActions, courseId], ); const handleDeleteXBlock = (usageId: string) => { @@ -147,13 +166,8 @@ const XBlockContainerIframe: FC = ({ dispatch(hideProcessingNotification()); }; - const handleRedirectToXBlockEditPage = (payload: { type: string, locator: string }) => { - navigate(`/course/${courseId}/editor/${payload.type}/${payload.locator}`); - }; - const messageHandlers = useMessageHandlers({ courseId, - navigate, dispatch, setIframeOffset, handleDeleteXBlock, @@ -167,7 +181,7 @@ const XBlockContainerIframe: FC = ({ handleOpenManageTagsModal, handleShowProcessingNotification, handleHideProcessingNotification, - handleRedirectToXBlockEditPage, + handleEditXBlock, }); useIframeMessages(messageHandlers); @@ -186,6 +200,43 @@ const XBlockContainerIframe: FC = ({ close={closeDeleteModal} onDeleteSubmit={onDeleteSubmit} /> + +
+ onXBlockSave} + /> +
+
+ +
+ onXBlockSave} + /> +
+
{Object.keys(accessManagedXBlockData).length ? ( = ({ @@ -42,7 +40,6 @@ const Editor: React.FC = ({ studioEndpointUrl, }, }); - const { fullScreen } = useEditorContext(); const EditorComponent = supportedEditors[blockType]; @@ -60,24 +57,7 @@ const Editor: React.FC = ({ ); } - const innerEditor = ; - - if (fullScreen) { - return ( -
-
- {innerEditor} -
-
- ); - } - return innerEditor; + return ; }; export default Editor; diff --git a/src/editors/EditorContext.tsx b/src/editors/EditorContext.tsx index e43b60a815..7a39298c7d 100644 --- a/src/editors/EditorContext.tsx +++ b/src/editors/EditorContext.tsx @@ -7,14 +7,6 @@ import React from 'react'; */ export interface EditorContext { learningContextId: string; - /** - * When editing components in the libraries part of the Authoring MFE, we show - * the editors in a modal (fullScreen = false). This is the preferred approach - * so that authors can see context behind the modal. - * However, when making edits from the legacy course view, we display the - * editors in a fullscreen view. This approach is deprecated. - */ - fullScreen: boolean; } const context = React.createContext(undefined); @@ -32,7 +24,6 @@ export function useEditorContext() { export const EditorContextProvider: React.FC<{ children: React.ReactNode, learningContextId: string; - fullScreen: boolean; }> = ({ children, ...contextData }) => { const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []); return {children}; diff --git a/src/editors/EditorPage.test.tsx b/src/editors/EditorPage.test.tsx index 66d7ffac95..9685906832 100644 --- a/src/editors/EditorPage.test.tsx +++ b/src/editors/EditorPage.test.tsx @@ -37,7 +37,6 @@ const defaultPropsHtml = { lmsEndpointUrl: 'http://lms.test.none/', studioEndpointUrl: 'http://cms.test.none/', onClose: jest.fn(), - fullScreen: false, }; const fieldsHtml = { displayName: 'Introduction to Testing', @@ -66,22 +65,6 @@ describe('EditorPage', () => { expect(modalElement.classList).not.toContain('pgn__modal-fullscreen'); }); - test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => { - jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( - { status: 200, data: snakeCaseObject(fieldsHtml) } - )); - - render(); - - // Then the editor should open - expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument(); - - const modalElement = screen.getByRole('dialog'); - expect(modalElement.classList).toContain('pgn__modal-fullscreen'); - expect(modalElement.classList).not.toContain('pgn__modal'); - expect(modalElement.classList).not.toContain('pgn__modal-xl'); - }); - test('it shows the Advanced Editor if there is no corresponding editor', async () => { jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line { status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } } diff --git a/src/editors/EditorPage.tsx b/src/editors/EditorPage.tsx index bb78903f4d..d7c80ed4e4 100644 --- a/src/editors/EditorPage.tsx +++ b/src/editors/EditorPage.tsx @@ -14,7 +14,6 @@ interface Props extends EditorComponent { isMarkdownEditorEnabledForCourse?: boolean; lmsEndpointUrl?: string; studioEndpointUrl?: string; - fullScreen?: boolean; children?: never; } @@ -31,7 +30,6 @@ const EditorPage: React.FC = ({ studioEndpointUrl = null, onClose = null, returnFunction = null, - fullScreen = true, }) => ( = ({ studioEndpointUrl, }} > - + { const dispatch = useDispatch(); const loading = hooks.useInitializeApp({ @@ -26,7 +28,7 @@ const VideoSelector = ({ return null; } return ( - + ); }; @@ -35,6 +37,8 @@ VideoSelector.propTypes = { learningContextId: PropTypes.string.isRequired, lmsEndpointUrl: PropTypes.string.isRequired, studioEndpointUrl: PropTypes.string.isRequired, + returnFunction: PropTypes.func, + onCancel: PropTypes.func, }; export default VideoSelector; diff --git a/src/editors/VideoSelectorPage.jsx b/src/editors/VideoSelectorPage.jsx index 0d9609b045..1f2fe1a768 100644 --- a/src/editors/VideoSelectorPage.jsx +++ b/src/editors/VideoSelectorPage.jsx @@ -10,6 +10,8 @@ const VideoSelectorPage = ({ courseId, lmsEndpointUrl, studioEndpointUrl, + returnFunction, + onCancel, }) => ( @@ -42,6 +46,8 @@ VideoSelectorPage.propTypes = { courseId: PropTypes.string, lmsEndpointUrl: PropTypes.string, studioEndpointUrl: PropTypes.string, + returnFunction: PropTypes.func, + onCancel: PropTypes.func, }; export default VideoSelectorPage; diff --git a/src/editors/containers/EditorContainer/index.test.tsx b/src/editors/containers/EditorContainer/index.test.tsx index 403f27f85a..f56a096655 100644 --- a/src/editors/containers/EditorContainer/index.test.tsx +++ b/src/editors/containers/EditorContainer/index.test.tsx @@ -32,7 +32,6 @@ const defaultPropsHtml = { lmsEndpointUrl: 'http://lms.test.none/', studioEndpointUrl: 'http://cms.test.none/', onClose: jest.fn(), - fullScreen: false, }; const fieldsHtml = { displayName: 'Introduction to Testing', diff --git a/src/editors/containers/EditorContainer/index.tsx b/src/editors/containers/EditorContainer/index.tsx index 670eb6ec1f..8495796d53 100644 --- a/src/editors/containers/EditorContainer/index.tsx +++ b/src/editors/containers/EditorContainer/index.tsx @@ -14,7 +14,6 @@ import { Close } from '@openedx/paragon/icons'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { EditorComponent } from '../../EditorComponent'; -import { useEditorContext } from '../../EditorContext'; import TitleHeader from './components/TitleHeader'; import * as hooks from './hooks'; import messages from './messages'; @@ -30,37 +29,18 @@ interface WrapperProps { } export const EditorModalWrapper: React.FC void }> = ({ children, onClose }) => { - const { fullScreen } = useEditorContext(); const intl = useIntl(); - if (fullScreen) { - return ( -
- {children} -
- ); - } + const title = intl.formatMessage(messages.modalTitle); return ( {children} ); }; -export const EditorModalBody: React.FC = ({ children }) => { - const { fullScreen } = useEditorContext(); - return { children }; -}; +export const EditorModalBody: React.FC = ({ children }) => { children }; -export const FooterWrapper: React.FC = ({ children }) => { - const { fullScreen } = useEditorContext(); - if (fullScreen) { - return
{children}
; - } - // eslint-disable-next-line react/jsx-no-useless-fragment - return <>{ children }; -}; +// eslint-disable-next-line react/jsx-no-useless-fragment +export const FooterWrapper: React.FC = ({ children }) => <>{ children }; interface Props extends EditorComponent { children: React.ReactNode; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx index ce2fbd7037..2cef8c36b8 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.test.tsx @@ -20,7 +20,7 @@ describe('SelectTypeModal', () => { jest.spyOn(hooks, 'onSelect').mockImplementation(mockSelect); // This is a new-style test, unlike most of the old snapshot-based editor tests. render( - + diff --git a/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap b/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap index 4f5e00fd45..a1f9a2506f 100644 --- a/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap +++ b/src/editors/containers/VideoEditor/__snapshots__/index.test.tsx.snap @@ -18,6 +18,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = ` "useSelector": [MockFunction], } } + onClose={[MockFunction props.onClose]} /> diff --git a/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx b/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx index 66f95b0ab5..55d36c3556 100644 --- a/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx +++ b/src/editors/containers/VideoEditor/components/VideoEditorModal.tsx @@ -7,7 +7,9 @@ import VideoSettingsModal from './VideoSettingsModal'; import { RequestKeys } from '../../../data/constants/requests'; interface Props { + onReturn?: (() => void); isLibrary: boolean; + onClose?: (() => void) | null; } export const { @@ -27,13 +29,15 @@ export const hooks = { const VideoEditorModal: React.FC = ({ isLibrary, + onClose, + onReturn, }) => { const dispatch = useDispatch(); const location = useLocation(); const searchParams = new URLSearchParams(location.search); const selectedVideoId = searchParams.get('selectedVideoId'); const selectedVideoUrl = searchParams.get('selectedVideoUrl'); - const onReturn = hooks.useReturnToGallery(); + const onSettingsReturn = onReturn || hooks.useReturnToGallery(); const isLoaded = useSelector( (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), ); @@ -44,8 +48,9 @@ const VideoEditorModal: React.FC = ({ return ( ); diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx index 7edbcb8be2..3718b3ca54 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/index.tsx @@ -20,11 +20,13 @@ import messages from '../../messages'; interface Props { onReturn: () => void; isLibrary: boolean; + onClose?: (() => void) | null; } const VideoSettingsModal: React.FC = ({ onReturn, isLibrary, + onClose, }) => ( <> {!isLibrary && ( @@ -32,7 +34,7 @@ const VideoSettingsModal: React.FC = ({ variant="link" className="text-primary-500" size="sm" - onClick={onReturn} + onClick={onClose || onReturn} style={{ textDecoration: 'none', marginLeft: '3px', diff --git a/src/editors/containers/VideoEditor/index.tsx b/src/editors/containers/VideoEditor/index.tsx index 0b3018812d..b9d291022c 100644 --- a/src/editors/containers/VideoEditor/index.tsx +++ b/src/editors/containers/VideoEditor/index.tsx @@ -39,7 +39,7 @@ const VideoEditor: React.FC = ({ > {(isCreateWorkflow || studioViewFinished) ? (
- +
) : (
{ const [highlighted, setHighlighted] = React.useState(null); const [ @@ -128,7 +129,10 @@ export const useVideoListProps = ({ }, selectBtnProps: { onClick: () => { - if (highlighted) { + /* istanbul ignore next */ + if (returnFunction) { + returnFunction()(); + } else if (highlighted) { navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`); } else { setShowSelectVideoError(true); @@ -138,10 +142,15 @@ export const useVideoListProps = ({ }; }; -export const useVideoUploadHandler = ({ replace }) => { +export const useVideoUploadHandler = ({ replace, uploadHandler }) => { const learningContextId = useSelector(selectors.app.learningContextId); const blockId = useSelector(selectors.app.blockId); const path = `/course/${learningContextId}/editor/video_upload/${blockId}`; + if (uploadHandler) { + return () => { + uploadHandler(); + }; + } if (replace) { return () => window.location.replace(path); } @@ -191,11 +200,12 @@ export const getstatusBadgeVariant = ({ status }) => { export const getStatusMessage = ({ status }) => Object.values(filterMessages).find((m) => m.defaultMessage === status); -export const useVideoProps = ({ videos }) => { +export const useVideoProps = ({ videos, uploadHandler, returnFunction }) => { const searchSortProps = useSearchAndSortProps(); const videoList = useVideoListProps({ searchSortProps, videos, + returnFunction, }); const { galleryError, @@ -203,7 +213,7 @@ export const useVideoProps = ({ videos }) => { inputError, selectBtnProps, } = videoList; - const fileInput = { click: useVideoUploadHandler({ replace: false }) }; + const fileInput = { click: useVideoUploadHandler({ replace: false, uploadHandler }) }; return { galleryError, diff --git a/src/editors/containers/VideoGallery/index.jsx b/src/editors/containers/VideoGallery/index.jsx index 5f5a8a8ca8..ac25c85268 100644 --- a/src/editors/containers/VideoGallery/index.jsx +++ b/src/editors/containers/VideoGallery/index.jsx @@ -1,5 +1,10 @@ -import React, { useEffect } from 'react'; -import { Image } from '@openedx/paragon'; +import React, { useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Image, useToggle, StandardModal, +} from '@openedx/paragon'; +import { useSearchParams } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { selectors } from '../../data/redux'; import * as hooks from './hooks'; @@ -8,8 +13,11 @@ import { acceptedImgKeys } from './utils'; import messages from './messages'; import { RequestKeys } from '../../data/constants/requests'; import videoThumbnail from '../../data/images/videoThumbnail.svg'; +import VideoUploadEditor from '../VideoUploadEditor'; +import VideoEditor from '../VideoEditor'; -const VideoGallery = () => { +const VideoGallery = ({ returnFunction, onCancel }) => { + const intl = useIntl(); const rawVideos = useSelector(selectors.app.videos); const isLoaded = useSelector( (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), @@ -21,14 +29,27 @@ const VideoGallery = () => { (state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }), ); const videos = hooks.buildVideos({ rawVideos }); - const handleVideoUpload = hooks.useVideoUploadHandler({ replace: true }); + const [isVideoUploadModalOpen, showVideoUploadModal, closeVideoUploadModal] = useToggle(); + const [isVideoEditorModalOpen, showVideoEditorModal, closeVideoEditorModal] = useToggle(); + const setSearchParams = useSearchParams()[1]; useEffect(() => { - // If no videos exists redirects to the video upload screen + // If no videos exists opens to the video upload modal if (isLoaded && videos.length === 0) { - handleVideoUpload(); + showVideoUploadModal(); } }, [isLoaded]); + + const onVideoUpload = useCallback((videoUrl) => { + closeVideoUploadModal(); + showVideoEditorModal(); + setSearchParams({ selectedVideoUrl: videoUrl }); + }, [closeVideoUploadModal, showVideoEditorModal, setSearchParams]); + + const uploadHandler = useCallback(() => { + showVideoUploadModal(); + }); + const { galleryError, inputError, @@ -36,7 +57,7 @@ const VideoGallery = () => { galleryProps, searchSortProps, selectBtnProps, - } = hooks.useVideoProps({ videos }); + } = hooks.useVideoProps({ videos, uploadHandler, returnFunction }); const handleCancel = hooks.useCancelHandler(); const modalMessages = { @@ -60,8 +81,8 @@ const VideoGallery = () => { { isFetchError, }} /> + +
+ +
+
+ {isVideoEditorModalOpen && ( + + )}
); }; -VideoGallery.propTypes = {}; +VideoGallery.propTypes = { + onCancel: PropTypes.func, + returnFunction: PropTypes.func, +}; export default VideoGallery; diff --git a/src/editors/containers/VideoGallery/index.test.jsx b/src/editors/containers/VideoGallery/index.test.jsx index 1cffffea82..6e45a02d27 100644 --- a/src/editors/containers/VideoGallery/index.test.jsx +++ b/src/editors/containers/VideoGallery/index.test.jsx @@ -6,6 +6,8 @@ import React from 'react'; import { act, fireEvent, render, screen, } from '@testing-library/react'; +import * as reactRouterDom from 'react-router-dom'; +import * as reduxThunks from '../../data/redux'; import VideoGallery from './index'; @@ -120,11 +122,10 @@ describe('VideoGallery', () => { expect(screen.getByText(video.client_video_id)).toBeInTheDocument() )); }); - it('navigates to video upload page when there are no videos', async () => { - expect(window.location.replace).not.toHaveBeenCalled(); + it('renders video upload modal when there are no videos', async () => { updateState({ videos: [] }); await renderComponent(); - expect(window.location.replace).toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: /upload or embed a new video/i })).toBeInTheDocument(); }); it.each([ [/newest/i, [2, 1, 3]], @@ -191,5 +192,36 @@ describe('VideoGallery', () => { expect(screen.queryByText('client_id_1')).not.toBeInTheDocument(); expect(screen.queryByText('client_id_3')).not.toBeInTheDocument(); }); + + it('calls onVideoUpload correctly when a video is uploaded', async () => { + // Mock useSearchParams + const setSearchParams = jest.fn(); + jest.spyOn(reactRouterDom, 'useSearchParams').mockReturnValue([{}, setSearchParams]); + + // Mock the uploadVideo thunk to immediately call postUploadRedirect + jest.spyOn(reduxThunks.thunkActions.video, 'uploadVideo').mockImplementation( + ({ postUploadRedirect }) => () => { + if (postUploadRedirect) { + postUploadRedirect('http://test.video/url.mp4'); + } + return { type: 'MOCK_UPLOAD_VIDEO' }; + }, + ); + + await renderComponent(); + + // Open the upload modal by clicking the button + const openModalButton = screen.getByRole('button', { name: /upload or embed a new video/i }); + fireEvent.click(openModalButton); + + // Wait for the input to appear in the modal + const urlInput = await screen.findByPlaceholderText('Paste your video ID or URL'); + fireEvent.change(urlInput, { target: { value: 'http://test.video/url.mp4' } }); + + const submitButton = screen.getByRole('button', { name: /submit/i }); + fireEvent.click(submitButton); + + expect(setSearchParams).toHaveBeenCalledWith({ selectedVideoUrl: 'http://test.video/url.mp4' }); + }); }); }); diff --git a/src/editors/containers/VideoGallery/messages.js b/src/editors/containers/VideoGallery/messages.js index e26dd63db3..3dd446b7c9 100644 --- a/src/editors/containers/VideoGallery/messages.js +++ b/src/editors/containers/VideoGallery/messages.js @@ -21,7 +21,16 @@ const messages = { defaultMessage: 'Upload or embed a new video', description: 'Label for upload button', }, - + videoUploadModalTitle: { + id: 'authoring.selectvideomodal.upload.title', + defaultMessage: 'Upload or embed a new video', + description: 'Label for upload modal', + }, + videoEditorModalTitle: { + id: 'authoring.selectvideomodal.edit.title', + defaultMessage: 'Edit selected video', + description: 'Label for editor modal', + }, // Sort Dropdown sortByDateNewest: { id: 'authoring.selectvideomodal.sort.datenewest.label', diff --git a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx index 028d1c085a..09d943db83 100644 --- a/src/editors/containers/VideoUploadEditor/VideoUploader.jsx +++ b/src/editors/containers/VideoUploadEditor/VideoUploader.jsx @@ -10,9 +10,9 @@ import { thunkActions } from '../../data/redux'; import * as hooks from './hooks'; import messages from './messages'; -const URLUploader = () => { +const URLUploader = ({ onUpload }) => { const [textInputValue, setTextInputValue] = React.useState(''); - const onURLUpload = hooks.onVideoUpload('selectedVideoUrl'); + const onURLUpload = hooks.onVideoUpload('selectedVideoUrl', onUpload); const intl = useIntl(); return (
@@ -58,16 +58,16 @@ const URLUploader = () => { ); }; -export const VideoUploader = ({ setLoading }) => { +export const VideoUploader = ({ setLoading, onUpload, onClose }) => { const dispatch = useDispatch(); const intl = useIntl(); - const goBack = hooks.useHistoryGoBack(); + const goBack = onClose || hooks.useHistoryGoBack(); const handleProcessUpload = ({ fileData }) => { dispatch(thunkActions.video.uploadVideo({ supportedFiles: [fileData], setLoadSpinner: setLoading, - postUploadRedirect: hooks.onVideoUpload('selectedVideoId'), + postUploadRedirect: hooks.onVideoUpload('selectedVideoId', onUpload), })); }; @@ -85,14 +85,20 @@ export const VideoUploader = ({ setLoading }) => { } + inputComponent={} />
); }; +URLUploader.propTypes = { + onUpload: PropTypes.func, +}; + VideoUploader.propTypes = { setLoading: PropTypes.func.isRequired, + onUpload: PropTypes.func, + onClose: PropTypes.func, }; export default VideoUploader; diff --git a/src/editors/containers/VideoUploadEditor/hooks.js b/src/editors/containers/VideoUploadEditor/hooks.js index a2774d9c60..3cc1f8468e 100644 --- a/src/editors/containers/VideoUploadEditor/hooks.js +++ b/src/editors/containers/VideoUploadEditor/hooks.js @@ -11,15 +11,20 @@ export const { navigateTo, } = appHooks; -export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl') => { +export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl', onUpload = null) => { const learningContextId = selectors.app.learningContextId(storeState); const blockId = selectors.app.blockId(storeState); + if (onUpload) { + return (videoUrl) => { + onUpload(videoUrl, learningContextId, blockId); + }; + } return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?${uploadType}=${videoUrl}`); }; -export const onVideoUpload = (uploadType) => { +export const onVideoUpload = (uploadType, onUpload) => { const storeState = store.getState(); - return module.postUploadRedirect(storeState, uploadType); + return module.postUploadRedirect(storeState, uploadType, onUpload); }; export const useUploadVideo = async ({ diff --git a/src/editors/containers/VideoUploadEditor/index.jsx b/src/editors/containers/VideoUploadEditor/index.jsx index ae2be7b5fb..91664d3e0a 100644 --- a/src/editors/containers/VideoUploadEditor/index.jsx +++ b/src/editors/containers/VideoUploadEditor/index.jsx @@ -1,17 +1,18 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Spinner } from '@openedx/paragon'; import './index.scss'; import messages from './messages'; import { VideoUploader } from './VideoUploader'; -const VideoUploadEditor = () => { +const VideoUploadEditor = ({ onUpload, onClose }) => { const [loading, setLoading] = React.useState(false); const intl = useIntl(); return (!loading) ? (
- +
) : (
{ ); }; +VideoUploadEditor.propTypes = { + onUpload: PropTypes.func, + onClose: PropTypes.func, +}; + export default VideoUploadEditor; diff --git a/src/library-authoring/components/ComponentEditorModal.tsx b/src/library-authoring/components/ComponentEditorModal.tsx index 74ffc85383..023bcb52a0 100644 --- a/src/library-authoring/components/ComponentEditorModal.tsx +++ b/src/library-authoring/components/ComponentEditorModal.tsx @@ -41,7 +41,6 @@ export const ComponentEditorModal: React.FC> = () => { lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={onClose} returnFunction={() => onClose} - fullScreen={false} /> ); }; From 212a54f76e949860d492f7a36d52d57e00ab7921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Thu, 22 May 2025 10:22:53 -0500 Subject: [PATCH 15/37] [Teak] fix: Inconsistent publish status filter menu placement & fix: Remove never published filter from component picker (#2021) * fix: Inconsistent publish status filter menu placement (#1966) * fix: Remove never published filter from component picker (#1947) Removes the never-published filter option from the component picker and unit picker. --- .../LibraryAuthoringPage.tsx | 22 ++++++- .../collections/LibraryCollectionPage.tsx | 4 +- .../component-picker/ComponentPicker.test.tsx | 39 ++++++++++++ .../generic/filter-by-published/index.tsx | 26 ++++++++ src/search-manager/FilterByPublished.tsx | 61 +++++++++---------- src/search-manager/data/api.ts | 2 + 6 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 src/library-authoring/generic/filter-by-published/index.tsx diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 95172f8d01..9d42611c14 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -32,7 +32,6 @@ import { ClearFiltersButton, FilterByBlockType, FilterByTags, - FilterByPublished, SearchContextProvider, SearchKeywordsField, SearchSortWidget, @@ -46,6 +45,7 @@ import { SidebarBodyComponentId, useSidebarContext } from './common/context/Side import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import messages from './messages'; +import LibraryFilterByPublished from './generic/filter-by-published'; const HeaderActions = () => { const intl = useIntl(); @@ -246,6 +246,17 @@ const LibraryAuthoringPage = ({ extraFilter.push(activeTypeFilters[activeKey]); } + /* + + {!(insideCollections || insideUnits) && } - + diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index de3c7ce234..943664788d 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -22,7 +22,6 @@ import NotFoundAlert from '../../generic/NotFoundAlert'; import { ClearFiltersButton, FilterByBlockType, - FilterByPublished, FilterByTags, SearchContextProvider, SearchKeywordsField, @@ -36,6 +35,7 @@ import { SidebarBodyComponentId, useSidebarContext } from '../common/context/Sid import messages from './messages'; import { LibrarySidebar } from '../library-sidebar'; import LibraryCollectionComponents from './LibraryCollectionComponents'; +import LibraryFilterByPublished from '../generic/filter-by-published'; const HeaderActions = () => { const intl = useIntl(); @@ -218,7 +218,7 @@ const LibraryCollectionPage = () => { - + diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx index 8e9fbbaf9d..2d492adf65 100644 --- a/src/library-authoring/component-picker/ComponentPicker.test.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx @@ -302,4 +302,43 @@ describe('', () => { expect(screen.queryByRole('tab', { name: /collections/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /components/i })).not.toBeInTheDocument(); }); + + it('should not display never published filter', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + const filterButton = await screen.findByRole('button', { name: /publish status/i }); + fireEvent.click(filterButton); + + // Verify the filters. Note: It's hard to verify the `published` filter, + // because there are many components with that text on the screen, but that's not the important thing. + expect(screen.getByText(/modified since publish/i)).toBeInTheDocument(); + expect(screen.queryByText(/never published/i)).not.toBeInTheDocument(); + }); + + it('should not display never published filter in collection page', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + await screen.findByText(/Change Library/i); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + + // Click on the collection card to open the sidebar + fireEvent.click(screen.queryAllByText('Collection 1')[0]); + + // Wait for the content library to load + const filterButton = await screen.findByRole('button', { name: /publish status/i }); + fireEvent.click(filterButton); + + // Verify the filters. Note: It's hard to verify the `published` filter, + // because there are many components with that text on the screen, but that's not the important thing. + expect(screen.getByText(/modified since publish/i)).toBeInTheDocument(); + expect(screen.queryByText(/never published/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/generic/filter-by-published/index.tsx b/src/library-authoring/generic/filter-by-published/index.tsx new file mode 100644 index 0000000000..825ac56f4d --- /dev/null +++ b/src/library-authoring/generic/filter-by-published/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useLibraryContext } from '../../common/context/LibraryContext'; +import { FilterByPublished, PublishStatus } from '../../../search-manager'; + +/** + * When browsing library content for insertion into a course, we only show published + * content. In that case, there is no need for a 'Never Published' filter, which will + * never show results. This component removes that option from FilterByPublished + * when not relevant. + */ +const LibraryFilterByPublished : React.FC> = () => { + const { showOnlyPublished } = useLibraryContext(); + + if (showOnlyPublished) { + return ( + + ); + } + + return ; +}; + +export default LibraryFilterByPublished; diff --git a/src/search-manager/FilterByPublished.tsx b/src/search-manager/FilterByPublished.tsx index f8ede2a956..079c6d43ee 100644 --- a/src/search-manager/FilterByPublished.tsx +++ b/src/search-manager/FilterByPublished.tsx @@ -10,12 +10,18 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import SearchFilterWidget from './SearchFilterWidget'; import { useSearchContext } from './SearchManager'; -import { PublishStatus } from './data/api'; +import { allPublishFilters, PublishStatus } from './data/api'; + +interface FilterByPublishedProps { + visibleFilters?: PublishStatus[], +} /** * A button with a dropdown that allows filtering the current search by publish status */ -const FilterByPublished: React.FC> = () => { +const FilterByPublished = ({ + visibleFilters = allPublishFilters, +}: FilterByPublishedProps) => { const intl = useIntl(); const { publishStatus, @@ -42,6 +48,26 @@ const FilterByPublished: React.FC> = () => { }; const appliedFilters = publishStatusFilter.map(mode => ({ label: modeToLabel[mode] })); + const filterLabels = { + [PublishStatus.Published]: intl.formatMessage(messages.publishStatusPublished), + [PublishStatus.Modified]: intl.formatMessage(messages.publishStatusModified), + [PublishStatus.NeverPublished]: intl.formatMessage(messages.publishStatusNeverPublished), + }; + + const visibleFiltersToRender = visibleFilters.map((filter) => ( + { toggleFilterMode(filter); }} + > +
+ {filterLabels[filter]} + {publishStatus[filter] ?? 0} +
+
+ )); + return ( > = () => { value={publishStatusFilter} > - { toggleFilterMode(PublishStatus.Published); }} - > -
- {intl.formatMessage(messages.publishStatusPublished)} - {publishStatus[PublishStatus.Published] ?? 0} -
-
- { toggleFilterMode(PublishStatus.Modified); }} - > -
- {intl.formatMessage(messages.publishStatusModified)} - {publishStatus[PublishStatus.Modified] ?? 0} -
-
- { toggleFilterMode(PublishStatus.NeverPublished); }} - > -
- {intl.formatMessage(messages.publishStatusNeverPublished)} - {publishStatus[PublishStatus.NeverPublished] ?? 0} -
-
+ {visibleFiltersToRender}
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index e1a6aeaa33..d829b8a527 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -31,6 +31,8 @@ export enum PublishStatus { NeverPublished = 'never', } +export const allPublishFilters: PublishStatus[] = Object.values(PublishStatus); + /** * Get the content search configuration from the CMS. */ From 317bc757cfa67d8aebb28dead2652d3b00bc8510 Mon Sep 17 00:00:00 2001 From: Jillian Date: Sat, 24 May 2025 04:33:57 +0930 Subject: [PATCH 16/37] fix: refresh xblock inline after accepting/rejecting library sync (#2022) (#2028) Instead of reloading the entire Unit after syncing changes from the library, just reload the xblock that was changed. (cherry picked from commit ac5574d2c41654a44201d389a92c7f8ae3b3141e) --- src/course-unit/preview-changes/index.test.tsx | 11 ++++++++--- src/course-unit/preview-changes/index.tsx | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index f88b436c73..a9775ab83c 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -85,7 +85,10 @@ describe('', () => { const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); userEvent.click(acceptBtn); await waitFor(() => { - expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); + expect(mockSendMessageToIframe).toHaveBeenCalledWith( + messageTypes.completeXBlockEditing, + { locator: usageKey }, + ); expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); @@ -100,7 +103,6 @@ describe('', () => { const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); userEvent.click(acceptBtn); await waitFor(() => { - expect(mockSendMessageToIframe).not.toHaveBeenCalledWith(messageTypes.refreshXBlock, null); expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); @@ -118,7 +120,10 @@ describe('', () => { const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' }); userEvent.click(ignoreConfirmBtn); await waitFor(() => { - expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); + expect(mockSendMessageToIframe).toHaveBeenCalledWith( + messageTypes.completeXBlockEditing, + { locator: usageKey }, + ); expect(axiosMock.history.delete.length).toEqual(1); expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey)); }); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index c038adf089..ea3ba6dac6 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -180,12 +180,14 @@ const IframePreviewLibraryXBlockChanges = () => { return null; } + const blockPayload = { locator: blockData.downstreamBlockId }; + return ( sendMessageToIframe(messageTypes.refreshXBlock, null)} + postChange={() => sendMessageToIframe(messageTypes.completeXBlockEditing, blockPayload)} /> ); }; From e34df7f270200b2e6e7d568351a9a8d4d2455b20 Mon Sep 17 00:00:00 2001 From: Jillian Date: Tue, 27 May 2025 03:35:48 +0930 Subject: [PATCH 17/37] fix: set maxHeight on TextEditor TinyMce widget [FC-0090] (#2024) (#2030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets a max_height=500px for the TinyMCE editor when editing a Text/Html component. This prevents the autoresize plugin from expanding the editor textarea beyond the bounds of the editor modal. ⚠️ Because the max height can only be a numeric pixel value, we can't use clever settings like vh or %, and so we're forced to limit the height of the editor to a fixed size for all screen sizes in order to address this issue. (cherry picked from commit c5f7d0cf3bcddb3bdefdbd9f14cf18eccac08e2e) --- .../containers/TextEditor/__snapshots__/index.test.jsx.snap | 6 +++--- src/editors/containers/TextEditor/index.jsx | 2 +- src/editors/sharedComponents/TinyMceWidget/hooks.js | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index d6c6e81617..1f8c21c76f 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -50,13 +50,13 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} @@ -226,13 +226,13 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} @@ -292,13 +292,13 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = ` } editorType="text" enableImageUpload={true} - height="100%" id={null} images={{}} initializeEditor={[MockFunction args.intializeEditor]} isLibrary={null} learningContextId="course+org+run" lmsEndpointUrl="" + maxHeight={500} minHeight={500} onChange={[Function]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx index 10bab16dbd..799740a356 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -65,7 +65,7 @@ const TextEditor = ({ editorContentHtml={editorContent} setEditorRef={setEditorRef} minHeight={500} - height="100%" + maxHeight={500} initializeEditor={initializeEditor} {...{ images, diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.js b/src/editors/sharedComponents/TinyMceWidget/hooks.js index 5afbba011c..3730e809b5 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.js @@ -304,6 +304,7 @@ export const editorConfig = ({ updateContent, content, minHeight, + maxHeight, learningContextId, staticRootUrl, enableImageUpload, @@ -335,6 +336,7 @@ export const editorConfig = ({ content_css: false, content_style: tinyMCEStyles + a11ycheckerCss, min_height: minHeight, + max_height: maxHeight, contextmenu: 'link table', directionality: isLocaleRtl ? 'rtl' : 'ltr', document_base_url: baseURL, From 7dfd93d4f1b90684595866341ed46adce2886ca3 Mon Sep 17 00:00:00 2001 From: Jillian Date: Thu, 29 May 2025 14:15:01 -0400 Subject: [PATCH 18/37] fix: upstreamInfo is not always provided (#2041) (#2042) (cherry picked from commit 3fc0f27d6797f2005371251c756bfaa6b39f6cf4) --- src/course-unit/CourseUnit.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index f63dad09e6..82994212f6 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -140,7 +140,7 @@ const CourseUnit = ({ courseId }) => { /> ) : null} - {courseUnit.upstreamInfo.upstreamLink && ( + {courseUnit.upstreamInfo?.upstreamLink && ( Date: Thu, 29 May 2025 16:06:35 -0300 Subject: [PATCH 19/37] fix: selection card wiggle (#2047) --- .../components/BaseCard.scss | 23 +++----------- .../units/LibraryUnitBlocks.tsx | 6 +--- src/library-authoring/units/index.scss | 30 ++++++++----------- 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/src/library-authoring/components/BaseCard.scss b/src/library-authoring/components/BaseCard.scss index 84f0453c89..5208ea221a 100644 --- a/src/library-authoring/components/BaseCard.scss +++ b/src/library-authoring/components/BaseCard.scss @@ -8,21 +8,11 @@ } &.selected:not(:focus) { - border: 2px $gray-700 solid; - - .library-item-header { - border-top-left-radius: calc(.375rem - 2px); - border-top-right-radius: calc(.375rem - 2px); - } + outline: 2px $gray-700 solid; } &.selected:focus { - border: 3px $gray-700 solid; - - .library-item-header { - border-top-left-radius: calc(.375rem - 3px); - border-top-right-radius: calc(.375rem - 3px); - } + outline: 3px $gray-700 solid; } &:not(.selected):focus { @@ -30,15 +20,10 @@ outline-offset: 2px; } - &:not(.selected) { - .library-item-header { - border-top-left-radius: .375rem; - border-top-right-radius: .375rem; - } - } - .library-item-header { padding: 0 .5rem 0 1.25rem; + border-top-left-radius: .375rem; + border-top-right-radius: .375rem; .library-item-header-icon { width: 2.3rem; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 8486e275b3..3fc4f8b7c2 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -177,16 +177,12 @@ const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => maxHeight: '200px', overflowY: 'hidden', }; - } if (componentId === block.originalId) { - return { - outline: '2px solid black', - }; } return {}; }, [isDragging, componentId, block]); const selected = sidebarComponentInfo?.type === SidebarBodyComponentId.ComponentInfo - && sidebarComponentInfo?.id === block.id; + && sidebarComponentInfo?.id === block.originalId; return ( diff --git a/src/library-authoring/units/index.scss b/src/library-authoring/units/index.scss index 4a8c2904b8..749e60b447 100644 --- a/src/library-authoring/units/index.scss +++ b/src/library-authoring/units/index.scss @@ -5,37 +5,33 @@ margin-bottom: 1rem; border: solid 1px $light-500; - &::before { - border: none !important; // Remove default focus + } + + .pgn__card.clickable { + box-shadow: none; + // this is required for clicks to be captured by card and iframe when it is not in focus + pointer-events: auto; + + &:focus { + // this is required for clicks to be passed to underlying iframe component + pointer-events: none; } &.selected:not(:focus) { - border: 2px $gray-700 solid; + outline: 2px $gray-700 solid; } &.selected:focus { - border: 3px $gray-700 solid; + outline: 3px $gray-700 solid; } &:not(.selected):focus { outline: 1px $gray-200 solid; outline-offset: 2px; } - } - - .pgn__card.clickable { - box-shadow: none; - // this is required for clicks to be captured by card and iframe when it is not in focus - pointer-events: auto; - - &:focus { - // this is required for clicks to be passed to underlying iframe component - pointer-events: none; - outline: solid 1px $dark-500; - } &::before { - border: none; + border: none !important; // Remove default focus } } From 2beb91c63b64760c92c10a07f4171bac6c637f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 2 Jun 2025 14:11:58 -0300 Subject: [PATCH 20/37] fix: set unit preview readonly on sidebar (#2008) (#2059) Make the unit preview on the sidebar read-only and add `Truncate` to the `InplaceTextEditor` --- src/generic/inplace-text-editor/index.tsx | 9 ++--- .../containers/UnitInfo.test.tsx | 16 +++++++++ src/library-authoring/containers/UnitInfo.tsx | 6 +++- .../units/LibraryUnitBlocks.tsx | 34 ++++++++++--------- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx index b1bbe531cc..89c97a2a04 100644 --- a/src/generic/inplace-text-editor/index.tsx +++ b/src/generic/inplace-text-editor/index.tsx @@ -6,6 +6,7 @@ import { Form, Icon, IconButton, + Truncate, Stack, } from '@openedx/paragon'; import { Edit } from '@openedx/paragon/icons'; @@ -68,9 +69,9 @@ export const InplaceTextEditor: React.FC = ({ // In that case, we show the new text instead of the original in read-only mode as an optimistic update. if (readOnly || pendingSaveText) { return ( - + {pendingSaveText || text} - + ); } @@ -91,9 +92,9 @@ export const InplaceTextEditor: React.FC = ({ /> ) : ( - + {text} - + )} ', () => { expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument(); expect(screen.getByText(/text block published 1/i)).toBeInTheDocument(); }); + + it('shows the preview tab by default and the component are readonly', async () => { + render(); + const previewTab = await screen.findByText('Preview'); + expect(previewTab).toBeInTheDocument(); + expect(previewTab).toHaveAttribute('aria-selected', 'true'); + + // Check that there are no edit buttons for components titles + expect(screen.queryAllByRole('button', { name: /edit/i }).length).toBe(0); + + // Check that there are no drag handle for components + expect(screen.queryAllByRole('button', { name: 'Drag to reorder' }).length).toBe(0); + + // Check that there are no menu buttons for components + expect(screen.queryAllByRole('button', { name: /component actions menu/i }).length).toBe(0); + }); }); diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index e143e3ff3c..cdf5c2d080 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -162,7 +162,11 @@ const UnitInfo = () => { activeKey={tab} onSelect={handleTabChange} > - {renderTab(UNIT_INFO_TABS.Preview, , intl.formatMessage(messages.previewTabTitle))} + {renderTab( + UNIT_INFO_TABS.Preview, + , + intl.formatMessage(messages.previewTabTitle), + )} {renderTab(UNIT_INFO_TABS.Manage, , intl.formatMessage(messages.manageTabTitle))} {renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))} diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 3fc4f8b7c2..e2d654cacd 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -49,12 +49,12 @@ interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata { interface ComponentBlockProps { block: LibraryBlockMetadataWithUniqueId; - preview?: boolean; + readOnly?: boolean; isDragging?: boolean; } /** Component header */ -const BlockHeader = ({ block }: ComponentBlockProps) => { +const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => { const intl = useIntl(); const { showOnlyPublished } = useLibraryContext(); const { showToast } = useContext(ToastContext); @@ -98,13 +98,13 @@ const BlockHeader = ({ block }: ComponentBlockProps) => { gap={2} className="font-weight-bold" // Prevent parent card from being clicked. - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ onClick={(e) => e.stopPropagation()} > @@ -112,7 +112,6 @@ const BlockHeader = ({ block }: ComponentBlockProps) => { direction="horizontal" gap={3} // Prevent parent card from being clicked. - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ onClick={(e) => e.stopPropagation()} > {!showOnlyPublished && block.hasUnpublishedChanges && ( @@ -126,15 +125,15 @@ const BlockHeader = ({ block }: ComponentBlockProps) => { )} - - + + {!readOnly && } ); }; /** ComponentBlock to render preview of given component under Unit */ -const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => { +const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => { const { showOnlyPublished } = useLibraryContext(); const { navigateTo } = useLibraryRoutes(); @@ -189,16 +188,16 @@ const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => } + actions={} actionStyle={{ borderRadius: '8px 8px 0px 0px', padding: '0.5rem 1rem', background: '#FBFAF9', borderBottom: 'solid 1px #E1DDDB', }} - isClickable - onClick={(e: { detail: number; }) => handleComponentSelection(e.detail)} - disabled={preview} + isClickable={!readOnly} + onClick={!readOnly ? (e: { detail: number; }) => handleComponentSelection(e.detail) : undefined} + disabled={readOnly} cardClassName={selected ? 'selected' : undefined} > {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} @@ -223,12 +222,12 @@ const ComponentBlock = ({ block, preview, isDragging }: ComponentBlockProps) => interface LibraryUnitBlocksProps { /** set to true if it is rendered as preview - * This disables drag and drop + * This disables drag and drop, title edit and menus */ - preview?: boolean; + readOnly?: boolean; } -export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { +export const LibraryUnitBlocks = ({ readOnly: componentReadOnly }: LibraryUnitBlocksProps) => { const intl = useIntl(); const [orderedBlocks, setOrderedBlocks] = useState([]); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); @@ -236,7 +235,9 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { const [hidePreviewFor, setHidePreviewFor] = useState(null); const { showToast } = useContext(ToastContext); - const { unitId, readOnly, showOnlyPublished } = useLibraryContext(); + const { unitId, readOnly: libraryReadOnly, showOnlyPublished } = useLibraryContext(); + + const readOnly = componentReadOnly || libraryReadOnly; const { openAddContentSidebar } = useSidebarContext(); @@ -301,10 +302,11 @@ export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { key={`${block.originalId}-${idx}-${block.modified}`} block={block} isDragging={hidePreviewFor === block.id} + readOnly={readOnly} /> ))} - {!preview && ( + {!readOnly && (
diff --git a/src/editors/sharedComponents/SourceCodeModal/index.jsx b/src/editors/sharedComponents/SourceCodeModal/index.jsx index 88f7ff9f8e..9282a180c4 100644 --- a/src/editors/sharedComponents/SourceCodeModal/index.jsx +++ b/src/editors/sharedComponents/SourceCodeModal/index.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { @@ -41,6 +40,7 @@ const SourceCodeModal = ({
From 1ff5e5bdae1cc2b1f166628cc67206b446a264bb Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:59:24 +0100 Subject: [PATCH 22/37] fix: markdown editor issues in modal (#2076) This PR resolves rendering issues with the Markdown editor inside the modal. The problem began after a PR [1] introduced the use of modals for the editor. The EditorPage [2] component expects a `isMarkdownEditorEnabledForCourse` prop, which was missing in that implementation. [1] https://github.com/openedx/frontend-app-authoring/pull/1838 [2] https://github.com/openedx/frontend-app-authoring/pull/1838/files#diff-147218ef88726880178ea895988a5d3feaf2c0c4459086a8de7a4080cbe37de7R226 Backports https://github.com/openedx/frontend-app-authoring/pull/2074 --- src/course-unit/add-component/AddComponent.jsx | 3 ++- src/course-unit/xblock-container-iframe/index.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 9e0fd850f7..2b169965a8 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -44,7 +44,7 @@ const AddComponent = ({ const [selectedComponents, setSelectedComponents] = useState([]); const [usageId, setUsageId] = useState(null); const { sendMessageToIframe } = useIframe(); - const { useVideoGalleryFlow } = useSelector(getWaffleFlags); + const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags); const receiveMessage = useCallback(({ data: { type, payload } }) => { if (type === messageTypes.showMultipleComponentPicker) { @@ -264,6 +264,7 @@ const AddComponent = ({ courseId={courseId} blockType={blockType} blockId={newBlockId} + isMarkdownEditorEnabledForCourse={useReactMarkdownEditor} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={closeXBlockEditorModal} diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 0c1ab91e24..dac95c96a8 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -49,7 +49,7 @@ const XBlockContainerIframe: FC = ({ const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); const [blockType, setBlockType] = useState(''); - const { useVideoGalleryFlow } = useSelector(getWaffleFlags); + const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags); const [newBlockId, setNewBlockId] = useState(''); const [accessManagedXBlockData, setAccessManagedXBlockData] = useState({}); const [iframeOffset, setIframeOffset] = useState(0); @@ -230,6 +230,7 @@ const XBlockContainerIframe: FC = ({ courseId={courseId} blockType={blockType} blockId={newBlockId} + isMarkdownEditorEnabledForCourse={useReactMarkdownEditor} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={closeXBlockEditorModal} From efb1a28b4d89d0cddcf624e7a90dbd7cf0afd825 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 5 Jun 2025 06:35:41 -0600 Subject: [PATCH 23/37] fix: Expand all now expands subsections (#2085) --- src/course-outline/CourseOutline.jsx | 1 + src/course-outline/CourseOutline.test.jsx | 61 ++++++------------- .../header-navigations/HeaderNavigations.jsx | 2 + .../subsection-card/SubsectionCard.jsx | 10 ++- 4 files changed, 29 insertions(+), 45 deletions(-) diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index e374388cde..2a5e33b09e 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -375,6 +375,7 @@ const CourseOutline = ({ courseId }) => { section, section.childInfo.children, )} + isSectionsExpanded={isSectionsExpanded} isSelfPaced={statusBarData.isSelfPaced} isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index cfd0191912..ed6b324000 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash'; import { closestCorners } from '@dnd-kit/core'; import { useLocation } from 'react-router-dom'; -import userEvent from '@testing-library/user-event'; import { getCourseBestPracticesApiUrl, getCourseLaunchApiUrl, @@ -289,13 +288,15 @@ describe('', () => { }); it('check that new section list is saved when dragged', async () => { - const { findAllByRole } = render(); - const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const { findAllByRole, findByTestId } = render(); + const expandAllButton = await findByTestId('expand-collapse-all-button'); + fireEvent.click(expandAllButton); + const [section] = store.getState().courseOutline.sectionsList; const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = sectionsDraggers[6]; + const draggableButton = sectionsDraggers[1]; axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) + .onPut(getCourseBlockApiUrl(section.id)) .reply(200, { dummy: 'value' }); const section1 = store.getState().courseOutline.sectionsList[0].id; @@ -314,13 +315,15 @@ describe('', () => { }); it('check section list is restored to original order when API call fails', async () => { - const { findAllByRole } = render(); - const courseBlockId = courseOutlineIndexMock.courseStructure.id; + const { findAllByRole, findByTestId } = render(); + const expandAllButton = await findByTestId('expand-collapse-all-button'); + fireEvent.click(expandAllButton); + const [section] = store.getState().courseOutline.sectionsList; const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); - const draggableButton = sectionsDraggers[6]; + const draggableButton = sectionsDraggers[1]; axiosMock - .onPut(getCourseBlockApiUrl(courseBlockId)) + .onPut(getCourseBlockApiUrl(section.id)) .reply(500); const section1 = store.getState().courseOutline.sectionsList[0].id; @@ -395,8 +398,6 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const units = await within(subsectionElement).findAllByTestId('unit-card'); expect(units.length).toBe(1); @@ -421,8 +422,6 @@ describe('', () => { render(); const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const units = await within(subsectionElement).findAllByTestId('unit-card'); expect(units.length).toBe(1); @@ -646,8 +645,6 @@ describe('', () => { await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); // check unit - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); @@ -660,8 +657,6 @@ describe('', () => { const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -700,8 +695,6 @@ describe('', () => { const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -771,8 +764,6 @@ describe('', () => { const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1481,8 +1472,6 @@ describe('', () => { const [firstSection] = await findAllByTestId('section-card'); const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card'); - const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(subsectionExpandButton); const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card'); const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button'); @@ -1842,8 +1831,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [, subsection] = section.childInfo.children; const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [, secondUnit] = subsection.childInfo.children; const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1883,8 +1870,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [firstSubsection, subsection] = section.childInfo.children; const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1920,8 +1905,6 @@ describe('', () => { const [subsection] = secondSection.childInfo.children; const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1]; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -1966,8 +1949,6 @@ describe('', () => { const [, sectionElement] = await findAllByTestId('section-card'); const [firstSubsection, subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const lastUnitIdx = firstSubsection.childInfo.children.length - 1; const unit = firstSubsection.childInfo.children[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; @@ -2005,8 +1986,6 @@ describe('', () => { const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex]; const thirdSectionFirstSubsection = thirdSection.childInfo.children[0]; const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex]; - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1; const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; @@ -2051,8 +2030,6 @@ describe('', () => { const sections = await findAllByTestId('section-card'); const [sectionElement] = sections; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(expandBtn)); // get first and only unit in the subsection const [firstUnit] = await within(subsectionElement).findAllByTestId('unit-card'); @@ -2072,8 +2049,6 @@ describe('', () => { const lastSection = sections[sections.length - 1]; // it has only one subsection const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card'); - const lastExpandBtn = await within(lastSubsectionElement).findByTestId('subsection-card-header__expanded-btn'); - await act(async () => fireEvent.click(lastExpandBtn)); // get last and the only unit in the subsection const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card'); @@ -2094,6 +2069,9 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); const [section] = store.getState().courseOutline.sectionsList; const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; @@ -2125,6 +2103,9 @@ describe('', () => { const { findAllByTestId } = render(); const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); const [section] = store.getState().courseOutline.sectionsList; const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; @@ -2154,8 +2135,6 @@ describe('', () => { const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const section = store.getState().courseOutline.sectionsList[2]; const [subsection] = section.childInfo.children; - const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = unitDraggers[1]; const sections = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -2190,8 +2169,6 @@ describe('', () => { const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const section = store.getState().courseOutline.sectionsList[2]; const [subsection] = section.childInfo.children; - const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn'); - fireEvent.click(expandBtn); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = unitDraggers[1]; const sections = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -2229,8 +2206,6 @@ describe('', () => { .onGet(getXBlockApiUrl(section.id)) .reply(200, courseSectionMock); let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); - userEvent.click(expandBtn); const [unit] = subsection.childInfo.children; const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.jsx index a6d71bd5f0..9f7ee50e77 100644 --- a/src/course-outline/header-navigations/HeaderNavigations.jsx +++ b/src/course-outline/header-navigations/HeaderNavigations.jsx @@ -66,6 +66,8 @@ const HeaderNavigations = ({ {hasSections && (
)} - {isExpanded && ( + {(isExpanded) && (
Date: Thu, 5 Jun 2025 08:11:21 -0600 Subject: [PATCH 24/37] fix: files & uploads menu was truncated due to overflow-x (#2071) (#2077) --- src/files-and-videos/files-page/FilesPage.jsx | 1 + src/files-and-videos/files-page/FilesPage.scss | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 src/files-and-videos/files-page/FilesPage.scss diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index be100bd55b..eee448d73d 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -32,6 +32,7 @@ import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; import FileValidationModal from './FileValidationModal'; +import './FilesPage.scss'; const FilesPage = ({ courseId, diff --git a/src/files-and-videos/files-page/FilesPage.scss b/src/files-and-videos/files-page/FilesPage.scss new file mode 100644 index 0000000000..6b27f995ae --- /dev/null +++ b/src/files-and-videos/files-page/FilesPage.scss @@ -0,0 +1,5 @@ +.files-table { + .pgn__data-table-container { + overflow-x: visible; + } +} From 3e737b5b0d800ddba0b3517be36c5516a2ccb018 Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Wed, 11 Jun 2025 22:25:47 +0200 Subject: [PATCH 25/37] fix: (backport) remove an extra editing xblock modal on unit page (#2111) (#2130) --- src/course-unit/CourseUnit.test.jsx | 6 ------ src/course-unit/add-component/AddComponent.jsx | 10 ++-------- src/course-unit/add-component/messages.js | 5 ----- src/course-unit/xblock-container-iframe/index.tsx | 10 ++-------- src/course-unit/xblock-container-iframe/messages.ts | 5 ----- 5 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index e168ec09e9..6985d3ce09 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -709,12 +709,6 @@ describe('', () => { userEvent.click(problemButton); }); - await waitFor(() => { - expect(screen.getByRole('heading', { - name: new RegExp(`${addComponentMessages.blockEditorModalTitle.defaultMessage}`, 'i'), - })).toBeInTheDocument(); - }); - axiosMock .onGet(getCourseUnitApiUrl(blockId)) .reply(200, courseUnitIndexMock); diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index 2b169965a8..1b3240a195 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -252,13 +252,7 @@ const AddComponent = ({ />
- + {isXBlockEditorModalOpen && (
onXBlockSave} />
-
+ )} ); } diff --git a/src/course-unit/add-component/messages.js b/src/course-unit/add-component/messages.js index f737a3bd33..117430edbb 100644 --- a/src/course-unit/add-component/messages.js +++ b/src/course-unit/add-component/messages.js @@ -36,11 +36,6 @@ const messages = defineMessages({ defaultMessage: 'Select video', description: 'Video picker modal title.', }, - blockEditorModalTitle: { - id: 'course-authoring.course-unit.modal.block-editor-title.text', - defaultMessage: 'Edit component', - description: 'Block editor modal title.', - }, modalContainerTitle: { id: 'course-authoring.course-unit.modal.container.title', defaultMessage: 'Add {componentTitle} component', diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index dac95c96a8..48be568b27 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -218,13 +218,7 @@ const XBlockContainerIframe: FC = ({ /> - + {isXBlockEditorModalOpen && (
= ({ returnFunction={/* istanbul ignore next */ () => onXBlockSave} />
-
+ )} {Object.keys(accessManagedXBlockData).length ? ( Date: Thu, 12 Jun 2025 09:16:53 -0700 Subject: [PATCH 26/37] fix: (backport) enable markdown editor in libraries (#2098) * fix: enable markdown editor for problems in libraries too This fix is also achieved on master via 5991fd39976ad336cde0f068e50af198d737bdad / https://github.com/openedx/frontend-app-authoring/pull/2068 but this is a simpler fix, not a direct backport of that refactor. * fix: remove duplicate markdown_edited save request (#2127) Removes the unnecessary duplicate save request of markdown_edited value to the backend. Part of: https://github.com/openedx/frontend-app-authoring/issues/2099 Backports: 62589aea5054040d1227f81d1290e73aa410ba19 --------- Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> --- .../data/redux/thunkActions/problem.test.ts | 14 ++------------ src/editors/data/redux/thunkActions/problem.ts | 16 ++++++++-------- .../components/ComponentEditorModal.tsx | 6 +++++- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index 17f7f85b0a..3c7edbe52c 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -10,7 +10,6 @@ import { } from './problem'; import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData'; import { ProblemTypeKeys } from '../../constants/problem'; -import * as requests from './requests'; const mockOlx = 'SOmEVALue'; const mockBuildOlx = jest.fn(() => mockOlx); @@ -72,22 +71,13 @@ describe('problem thunkActions', () => { ); }); test('switchToMarkdownEditor dispatches correct actions', () => { - switchToMarkdownEditor()(dispatch, getState); + switchToMarkdownEditor()(dispatch); expect(dispatch).toHaveBeenCalledWith( actions.problem.updateField({ isMarkdownEditorEnabled: true, }), ); - - expect(dispatch).toHaveBeenCalledWith( - requests.saveBlock({ - content: { - settings: { markdown_edited: true }, - olx: blockValue.data.data, - }, - }), - ); }); describe('switchEditor', () => { @@ -110,7 +100,7 @@ describe('problem thunkActions', () => { test('dispatches switchToMarkdownEditor when editorType is markdown', () => { switchEditor('markdown')(dispatch, getState); - expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch, getState); + expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch); }); }); diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts index 28ba7b34fc..74876fdb1d 100644 --- a/src/editors/data/redux/thunkActions/problem.ts +++ b/src/editors/data/redux/thunkActions/problem.ts @@ -24,17 +24,17 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => { dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX })); }; -export const switchToMarkdownEditor = () => (dispatch, getState) => { - const state = getState(); +export const switchToMarkdownEditor = () => (dispatch) => { dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true })); - const { blockValue } = state.app; - const olx = get(blockValue, 'data.data', ''); - const content = { settings: { markdown_edited: true }, olx }; - // Sending a request to save the problem block with the updated markdown_edited value - dispatch(requests.saveBlock({ content })); }; -export const switchEditor = (editorType) => (dispatch, getState) => (editorType === 'advanced' ? switchToAdvancedEditor : switchToMarkdownEditor)()(dispatch, getState); +export const switchEditor = (editorType) => (dispatch, getState) => { + if (editorType === 'advanced') { + switchToAdvancedEditor()(dispatch, getState); + } else { + switchToMarkdownEditor()(dispatch); + } +}; export const isBlankProblem = ({ rawOLX }) => { if (['', ''].includes(rawOLX.replace(/\s/g, ''))) { diff --git a/src/library-authoring/components/ComponentEditorModal.tsx b/src/library-authoring/components/ComponentEditorModal.tsx index 023bcb52a0..08df29a760 100644 --- a/src/library-authoring/components/ComponentEditorModal.tsx +++ b/src/library-authoring/components/ComponentEditorModal.tsx @@ -1,7 +1,9 @@ import { getConfig } from '@edx/frontend-platform'; import React from 'react'; - +import { useSelector } from 'react-redux'; import { useQueryClient } from '@tanstack/react-query'; + +import { getWaffleFlags } from '../../data/selectors'; import EditorPage from '../../editors/EditorPage'; import { getBlockType } from '../../generic/key-utils'; import { useLibraryContext } from '../common/context/LibraryContext'; @@ -21,6 +23,7 @@ export function canEditComponent(usageKey: string): boolean { export const ComponentEditorModal: React.FC> = () => { const { componentBeingEdited, closeComponentEditor, libraryId } = useLibraryContext(); const queryClient = useQueryClient(); + const { useReactMarkdownEditor } = useSelector(getWaffleFlags); if (componentBeingEdited === undefined) { return null; @@ -37,6 +40,7 @@ export const ComponentEditorModal: React.FC> = () => { courseId={libraryId} blockType={blockType} blockId={componentBeingEdited.usageKey} + isMarkdownEditorEnabledForCourse={useReactMarkdownEditor} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} onClose={onClose} From 86d0a7e7dbde2de231fa4849c8d9a36fe93a9878 Mon Sep 17 00:00:00 2001 From: Diana Villalvazo Date: Thu, 12 Jun 2025 15:43:18 -0600 Subject: [PATCH 27/37] fix: remove icon and empty breadcrumb from libraries (#2129) (#2133) --- src/library-authoring/LibraryAuthoringPage.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 9d42611c14..61e33df096 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -15,12 +15,11 @@ import { Breadcrumb, Button, Container, - Icon, Stack, Tab, Tabs, } from '@openedx/paragon'; -import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons'; +import { Add, InfoOutline } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import Loading from '../generic/Loading'; @@ -214,16 +213,11 @@ const LibraryAuthoringPage = ({ const breadcumbs = componentPickerMode && !restrictToLibrary ? ( } linkAs={Link} /> ) : undefined; From 4ba8cde5878062e4ebd47c051104f85c94a35753 Mon Sep 17 00:00:00 2001 From: bydawen Date: Tue, 17 Jun 2025 00:43:02 +0300 Subject: [PATCH 28/37] fix: (backport) text truncate issue in the search modal (#2151) --- src/search-modal/SearchModal.scss | 9 +++++++++ src/search-modal/SearchResult.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss index d14fdac832..b58495c460 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -66,4 +66,13 @@ background-color: unset; } } + + // Fix a bug with search modal: very long text is not truncated with an ellipsis + // https://github.com/openedx/frontend-app-authoring/issues/1900 + .hit-description { + display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */ + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } } diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 032d5cda1a..6f82a0f045 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -181,7 +181,7 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
-
+
From c9896a8fe5ce6f31ad5fcaf2364ff4c49c7b6462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Tue, 17 Jun 2025 19:58:57 -0500 Subject: [PATCH 29/37] [Teak] fix: published name in unit sidebar in container picker & Issues on Inplace Editor (#2140) Backport of fix: show unit published name in sidebar on content picker [FC-0090] #2100 Backport of fix: Issue on the Inplace editor [FC-0090] #2101 --- .../InplaceTextEditor.test.tsx | 21 ++++++++ src/generic/inplace-text-editor/index.tsx | 22 +++++---- .../LibraryAuthoringPage.tsx | 2 +- .../__mocks__/library-search.json | 12 +++-- .../component-picker/ComponentPicker.test.tsx | 20 ++++++++ .../containers/ContainerEditableTitle.tsx | 48 +++++++++++++++++++ .../containers/ContainerInfoHeader.tsx | 39 ++------------- src/library-authoring/data/api.mocks.ts | 1 + src/library-authoring/data/api.ts | 1 + src/library-authoring/data/apiHooks.ts | 2 +- 10 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 src/library-authoring/containers/ContainerEditableTitle.tsx diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx index c4dbc46191..9b7a55c281 100644 --- a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx +++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx @@ -110,4 +110,25 @@ describe('', () => { // Show original text expect(screen.getByText('Test text')).toBeInTheDocument(); }); + + it('should disappear edit button while editing', async () => { + render(); + + const title = screen.getByText('Test text'); + expect(title).toBeInTheDocument(); + + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(editButton).toBeInTheDocument(); + fireEvent.click(editButton); + + const textBox = screen.getByRole('textbox'); + expect(editButton).not.toBeInTheDocument(); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(mockOnSave).toHaveBeenCalledWith('New text'); + expect(await screen.findByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); }); diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx index 89c97a2a04..cb07d2ffa9 100644 --- a/src/generic/inplace-text-editor/index.tsx +++ b/src/generic/inplace-text-editor/index.tsx @@ -92,17 +92,19 @@ export const InplaceTextEditor: React.FC = ({ /> ) : ( - - {text} - + <> + + {text} + + + )} - ); }; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 61e33df096..b49ae07e76 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -113,7 +113,7 @@ export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { const showReadOnlyBadge = readOnly && !componentPickerMode; return ( - + {title} {showReadOnlyBadge && (
diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index 9eee970312..ba27af2185 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -494,7 +494,7 @@ ], "created": 1742221203.895054, "modified": 1742221203.895054, - "usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", + "usage_key": "lct:org:lib:unit:test-unit-9a207", "block_type": "unit", "context_key": "lib:Axim:TEST", "org": "Axim", @@ -512,12 +512,18 @@ ], "created": "1742221203.895054", "modified": "1742221203.895054", - "usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", + "usage_key": "lct:org:lib:unit:test-unit-9a207", "block_type": "unit", "context_key": "lib:Axim:TEST", "org": "Axim", "access_id": "15", - "num_children": "0" + "num_children": "0", + "published": { + "display_name": "Published Test Unit" + } + }, + "published": { + "display_name": "Published Test Unit" } } ], diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx index 2d492adf65..cd7f497a37 100644 --- a/src/library-authoring/component-picker/ComponentPicker.test.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx @@ -14,6 +14,7 @@ import { mockGetCollectionMetadata, mockGetContentLibraryV2List, mockLibraryBlockMetadata, + mockGetContainerMetadata, } from '../data/api.mocks'; import { ComponentPicker } from './ComponentPicker'; @@ -40,6 +41,7 @@ mockContentSearchConfig.applyMock(); mockGetCollectionMetadata.applyMock(); mockGetContentLibraryV2List.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockGetContainerMetadata.applyMock(); let postMessageSpy: jest.SpyInstance; @@ -99,6 +101,24 @@ describe('', () => { }, '*'); }); + it('should open the unit sidebar', async () => { + render(); + + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); + + // Wait for the content library to load + await screen.findByText(/Change Library/i); + expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); + + // Click on the unit card to open the sidebar + fireEvent.click((await screen.findByText('Published Test Unit'))); + + const sidebar = await screen.findByTestId('library-sidebar'); + expect(sidebar).toBeInTheDocument(); + await waitFor(() => expect(within(sidebar).getByText('Published Test Unit')).toBeInTheDocument()); + }); + it('should pick component inside a collection using the card', async () => { render(); diff --git a/src/library-authoring/containers/ContainerEditableTitle.tsx b/src/library-authoring/containers/ContainerEditableTitle.tsx new file mode 100644 index 0000000000..5a1ea0f6df --- /dev/null +++ b/src/library-authoring/containers/ContainerEditableTitle.tsx @@ -0,0 +1,48 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useContext } from 'react'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; +import { ToastContext } from '../../generic/toast-context'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContainer, useUpdateContainer } from '../data/apiHooks'; +import messages from './messages'; + +interface EditableTitleProps { + containerId: string; + textClassName?: string; +} + +export const ContainerEditableTitle = ({ containerId, textClassName }: EditableTitleProps) => { + const intl = useIntl(); + + const { readOnly, showOnlyPublished } = useLibraryContext(); + + const { data: container } = useContainer(containerId); + + const updateMutation = useUpdateContainer(containerId); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = async (newDisplayName: string) => { + try { + await updateMutation.mutateAsync({ + displayName: newDisplayName, + }); + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + } catch (err) { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + } + }; + + // istanbul ignore if: this should never happen + if (!container) { + return null; + } + + return ( + + ); +}; diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index 65357c0f14..c47a4ee6b9 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -1,17 +1,7 @@ -import { useContext } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { InplaceTextEditor } from '../../generic/inplace-text-editor'; -import { ToastContext } from '../../generic/toast-context'; -import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useContainer, useUpdateContainer } from '../data/apiHooks'; -import messages from './messages'; +import { ContainerEditableTitle } from './ContainerEditableTitle'; const ContainerInfoHeader = () => { - const intl = useIntl(); - - const { readOnly } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); const containerId = sidebarComponentInfo?.id; @@ -20,32 +10,9 @@ const ContainerInfoHeader = () => { throw new Error('containerId is required'); } - const { data: container } = useContainer(containerId); - - const updateMutation = useUpdateContainer(containerId); - const { showToast } = useContext(ToastContext); - - const handleSaveDisplayName = async (newDisplayName: string) => { - try { - await updateMutation.mutateAsync({ - displayName: newDisplayName, - }); - showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - } catch (err) { - showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - throw err; - } - }; - - if (!container) { - return null; - } - return ( - ); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 3ff35e624d..92220b140b 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -495,6 +495,7 @@ mockGetContainerMetadata.containerData = { id: 'lct:org:lib:unit:test-unit-9a2072', containerType: 'unit', displayName: 'Test Unit', + publishedDisplayName: 'Published Test Unit', created: '2024-09-19T10:00:00Z', createdBy: 'test_author', lastPublished: '2024-09-20T10:00:00Z', diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 92f5d3f40b..dd8a8f7430 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -600,6 +600,7 @@ export interface Container { id: string; containerType: 'unit'; displayName: string; + publishedDisplayName: string; lastPublished: string | null; publishedBy: string | null; createdBy: string | null; diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 65b9445ad1..3fd1f0633e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -615,7 +615,7 @@ export const useUpdateContainer = (containerId: string) => { return useMutation({ mutationFn: (data: api.UpdateContainerDataRequest) => api.updateContainerMetadata(containerId, data), onMutate: (data) => { - const previousData = queryClient.getQueryData(containerQueryKey) as api.CollectionMetadata; + const previousData = queryClient.getQueryData(containerQueryKey) as api.Container; queryClient.setQueryData(containerQueryKey, { ...previousData, ...data, From b6bd94c114fac5ac1d81a9302b8b8a4fc548067b Mon Sep 17 00:00:00 2001 From: Arunmozhi Date: Wed, 11 Jun 2025 01:46:27 +1000 Subject: [PATCH 30/37] feat: add `v2` `CourseAuthoringUnitSidebarSlot` (#2000) --- src/course-unit/CourseUnit.jsx | 30 ++---- .../CourseAuthoringUnitSidebarSlot/README.md | 13 +-- .../README.v1.md | 95 +++++++++++++++++++ .../CourseAuthoringUnitSidebarSlot/index.tsx | 50 +++++++--- 4 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 82994212f6..10a9ad25b0 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -3,8 +3,7 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { - Container, Layout, Stack, Button, TransitionReplace, - Alert, + Alert, Container, Layout, Button, TransitionReplace, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -27,8 +26,6 @@ import AddComponent from './add-component/AddComponent'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import Sequence from './course-sequence'; -import Sidebar from './sidebar'; -import SplitTestSidebarInfo from './sidebar/SplitTestSidebarInfo'; import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks'; import messages from './messages'; import { PasteNotificationAlert } from './clipboard'; @@ -244,22 +241,15 @@ const CourseUnit = ({ courseId }) => { - - {isUnitVerticalType && ( - - )} - {isSplitTestType && ( - - - - )} - + diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md index ee00e7ba16..091af0d8df 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md @@ -1,9 +1,8 @@ # CourseAuthoringUnitSidebarSlot -### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v1` +### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v2` -### Slot ID Aliases -* `course_authoring_unit_sidebar_slot` +### Previous Version: [`org.openedx.frontend.authoring.course_unit_sidebar.v1`](./README.v1.md) ### Plugin Props: @@ -12,6 +11,8 @@ * `unitTitle` - String. The name of the current unit being viewed / edited. * `xBlocks` - Array of Objects. List of XBlocks in the Unit. Object structure defined in `index.tsx`. * `readOnly` - Boolean. True if the user should not be able to edit the contents of the unit. +* `isUnitVerticalType` - Boolean. If the unit category is `vertical`. +* `isSplitTestType` - Boolean. If the unit category is `split_test`. ## Description @@ -29,7 +30,7 @@ import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; const config = { pluginSlots: { - 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + 'org.openedx.frontend.authoring.course_unit_sidebar.v2': { keepDefault: true, plugins: [ { @@ -63,11 +64,11 @@ const ProblemBlocks = ({unitTitle, xBlocks}) => ( } -); +); const config = { pluginSlots: { - 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + 'org.openedx.frontend.authoring.course_unit_sidebar.v2': { keepDefault: true, plugins: [ { diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md new file mode 100644 index 0000000000..aafccfb461 --- /dev/null +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.v1.md @@ -0,0 +1,95 @@ +# CourseAuthoringUnitSidebarSlot + +### Slot ID: `org.openedx.frontend.authoring.course_unit_sidebar.v1` + +### Slot ID Aliases: `course_authoring_unit_sidebar_slot` + +### Plugin Props: + +* `courseId` - String. +* `blockId` - String. The usage id of the current unit being viewed / edited. +* `unitTitle` - String. The name of the current unit being viewed / edited. +* `xBlocks` - Array of Objects. List of XBlocks in the Unit. Object structure defined in `index.tsx`. +* `readOnly` - Boolean. True if the user should not be able to edit the contents of the unit. + +### Description + +The slot wraps the sidebar that is displayed on the unit editor page. It can +be used to add additional sidebar components or modify the existing sidebar. + +> [!IMPORTANT] +> This document describes an older version `v1` of the `CourseAuthoringUnitSidebarSlot`. +> It is recommended to use the `org.openedx.frontend.authoring.course_unit_sidebar.v2` slot ID for new plugins. + +The `v1` slot has the following limitations compared to the `v2` version: +* It renders conditionally based on the `isUnitVerticalType` prop, which means the plugins won't be rendered in other scenarios like unit with library blocks. +* It does **not** wrap the `SplitTestSidebarInfo` component. So it can't be hidden from the sidebar by overriding the components in the slot. +* As it is not the primary child component of the sidebar, CSS styling for inserted components face limitations, such as an inability to be `sticky` or achieve 100% height. + +## Example 1 + +![Screenshot of the unit sidebar surrounded by border](./images/unit_sidebar_with_border.png) + +The following example configuration surrounds the sidebar in a border as shown above. + +```js +import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Wrap, + widgetId: 'default_contents', + wrapper: ({ component }) => ( +
{component}
+ ), + }, + ], + }, + } +}; +export default config; +``` + +## Example 2 + +![Screenshot of the unit sidebar with an extra component listing all the problem blocks](./images/unit_sidebar_with_problem_blocks_list.png) + +```js +import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework'; + +const ProblemBlocks = ({unitTitle, xBlocks}) => ( + <> +

{unitTitle}: Problem Blocks

+
    + {xBlocks + .filter(block => block.blockType === "problem") + .map(block =>
  • {block.displayName}
  • ) + } +
+ +); + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.course_unit_sidebar.v1': { + keepDefault: true, + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget:{ + id: 'problem-blocks-list', + priority: 1, + type: DIRECT_PLUGIN, + RenderWidget: ProblemBlocks, + } + }, + ], + }, + } +}; +export default config; +``` diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx index 0bf6de14ca..562cbccb0f 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx @@ -1,9 +1,11 @@ import { getConfig } from '@edx/frontend-platform'; import { PluginSlot } from '@openedx/frontend-plugin-framework/dist'; +import { Stack } from '@openedx/paragon'; import TagsSidebarControls from '../../content-tags-drawer/tags-sidebar-controls'; import Sidebar from '../../course-unit/sidebar'; import LocationInfo from '../../course-unit/sidebar/LocationInfo'; import PublishControls from '../../course-unit/sidebar/PublishControls'; +import SplitTestSidebarInfo from '../../course-unit/sidebar/SplitTestSidebarInfo'; export const CourseAuthoringUnitSidebarSlot = ( { @@ -12,26 +14,44 @@ export const CourseAuthoringUnitSidebarSlot = ( unitTitle, xBlocks, readOnly, + isUnitVerticalType, + isSplitTestType, }: CourseAuthoringUnitSidebarSlotProps, ) => ( - - - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - - - - )} - - - + + {isUnitVerticalType && ( + + + + + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + + + )} + + + + + )} + {isSplitTestType && ( + + + + )} + ); @@ -47,4 +67,6 @@ interface CourseAuthoringUnitSidebarSlotProps { unitTitle: string; xBlocks: XBlock[]; readOnly: boolean; + isUnitVerticalType: boolean; + isSplitTestType: boolean; } From 92c59cbf0c3cbe4e6a439ea56d8e9319ce552cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ignacio=20Palma?= Date: Thu, 19 Jun 2025 18:06:31 +0200 Subject: [PATCH 31/37] fix: advanced-settings api should not camel-case return value (backport) (#2087) * fix: advanced-settings api should not camel-case return value (#1581) * fix: update advanced module list not working (#2189) Backend was still expecting `{'advanced_modules', {'value': ['poll', 'problem-builder', 'h5pxblock']}}` but without this change, it was receiving `{'advancedModules', ['poll', 'problem-builder', 'h5pxblock']}` Follow up to https://github.com/openedx/frontend-app-authoring/pull/1581 --------- Co-authored-by: Muhammad Faraz Maqsood --- src/advanced-settings/data/api.js | 49 ++++- src/advanced-settings/data/api.test.js | 236 +++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/advanced-settings/data/api.test.js diff --git a/src/advanced-settings/data/api.js b/src/advanced-settings/data/api.js index f240357c9f..92a8a97272 100644 --- a/src/advanced-settings/data/api.js +++ b/src/advanced-settings/data/api.js @@ -1,5 +1,10 @@ -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +/* eslint-disable import/prefer-default-export */ +import { + camelCaseObject, + getConfig, +} from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCase } from 'lodash'; import { convertObjectToSnakeCase } from '../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -14,7 +19,19 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/ export async function getCourseAdvancedSettings(courseId) { const { data } = await getAuthenticatedHttpClient() .get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } /** @@ -26,7 +43,19 @@ export async function getCourseAdvancedSettings(courseId) { export async function updateCourseAdvancedSettings(courseId, settings) { const { data } = await getAuthenticatedHttpClient() .patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings)); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } /** @@ -36,5 +65,17 @@ export async function updateCourseAdvancedSettings(courseId, settings) { */ export async function getProctoringExamErrors(courseId) { const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`); - return camelCaseObject(data); + const keepValues = {}; + Object.keys(data).forEach((key) => { + keepValues[camelCase(key)] = { value: data[key].value }; + }); + const formattedData = {}; + const formattedCamelCaseData = camelCaseObject(data); + Object.keys(formattedCamelCaseData).forEach((key) => { + formattedData[key] = { + ...formattedCamelCaseData[key], + value: keepValues[key]?.value, + }; + }); + return formattedData; } diff --git a/src/advanced-settings/data/api.test.js b/src/advanced-settings/data/api.test.js new file mode 100644 index 0000000000..5679d3ebfa --- /dev/null +++ b/src/advanced-settings/data/api.test.js @@ -0,0 +1,236 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + getCourseAdvancedSettings, + updateCourseAdvancedSettings, + getProctoringExamErrors, +} from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +describe('courseSettings API', () => { + const mockHttpClient = { + get: jest.fn(), + patch: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + getAuthenticatedHttpClient.mockReturnValue(mockHttpClient); + }); + + describe('getCourseAdvancedSettings', () => { + it('should fetch and unformat course advanced settings', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', + UPPERCASE: 'To come lowercase', + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.get.mockResolvedValue({ data: fakeData }); + + const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024'); + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`, + ); + expect(result).toEqual(expected); + }); + }); + + describe('updateCourseAdvancedSettings', () => { + it('should update and unformat course advanced settings', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', // because already be camelCase + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted + UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.patch.mockResolvedValue({ data: fakeData }); + + const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {}); + expect(mockHttpClient.patch).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`, + {}, + ); + expect(result).toEqual(expected); + }); + }); + + describe('getProctoringExamErrors', () => { + it('should fetch proctoring errors and return unformat object', async () => { + const fakeData = { + key_snake_case: { + display_name: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + PascalCase: 'To come camelCase', + 'kebab-case': 'To come camelCase', + UPPER_CASE: 'To come camelCase', + lowercase: 'This key must not be formatted', + UPPERCASE: 'To come lowercase', + 'Title Case': 'To come camelCase', + 'dot.case': 'To come camelCase', + SCREAMING_SNAKE_CASE: 'To come camelCase', + MixedCase: 'To come camelCase', + 'Train-Case': 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + // value is an object with various cases + // this contain must not be formatted to camelCase + value: { + snake_case: 'snake_case', + camelCase: 'camelCase', + PascalCase: 'PascalCase', + 'kebab-case': 'kebab-case', + UPPER_CASE: 'UPPER_CASE', + lowercase: 'lowercase', + UPPERCASE: 'UPPERCASE', + 'Title Case': 'Title Case', + 'dot.case': 'dot.case', + SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE', + MixedCase: 'MixedCase', + 'Train-Case': 'Train-Case', + nestedOption: { + anotherOption: 'nestedContent', + }, + }, + }, + }; + const expected = { + keySnakeCase: { + displayName: 'To come camelCase', + testCamelCase: 'This key must not be formatted', + pascalCase: 'To come camelCase', + kebabCase: 'To come camelCase', + upperCase: 'To come camelCase', + lowercase: 'This key must not be formatted', + uppercase: 'To come lowercase', + titleCase: 'To come camelCase', + dotCase: 'To come camelCase', + screamingSnakeCase: 'To come camelCase', + mixedCase: 'To come camelCase', + trainCase: 'To come camelCase', + nestedOption: { + anotherOption: 'To come camelCase', + }, + value: fakeData.key_snake_case.value, + }, + }; + + mockHttpClient.get.mockResolvedValue({ data: fakeData }); + + const result = await getProctoringExamErrors('course-v1:Test+T101+2024'); + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`, + ); + expect(result).toEqual(expected); + }); + }); +}); From bdc99fddc3d850268994cabb03c56d1bfe3fcae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brayan=20Cer=C3=B3n?= <86393372+bra-i-am@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:47:45 -0500 Subject: [PATCH 32/37] fix: clear selection on files & uploads page after deleting (backport) (#2228) * refactor: remove selected rows when deleting or adding elements * refactor: ensure unique asset IDs when adding new ones * refactor: remove unnecessary loading checks in mockStore function * test: add unit tests for TableActions component --- .../files-page/FilesPage.test.jsx | 9 -- src/files-and-videos/files-page/data/slice.js | 2 +- .../generic/DeleteConfirmationModal.jsx | 12 +- src/files-and-videos/generic/FileTable.jsx | 20 +-- .../generic/table-components/TableActions.jsx | 9 +- .../table-components/TableActions.test.jsx | 138 ++++++++++++++++++ 6 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 src/files-and-videos/generic/table-components/TableActions.test.jsx diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 80bb8f1f8c..948d36a35e 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -70,15 +70,6 @@ const mockStore = async ( } renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); - - // Finish loading the expected files into the data table before returning, - // because loading new files can disrupt things like accessing file menus. - if (status === RequestStatus.SUCCESSFUL) { - const numFiles = skipNextPageFetch ? 13 : 15; - await waitFor(() => { - expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument(); - }); - } }; const emptyMockStore = async (status) => { diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index 3a96779185..4fbe4915c9 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -28,7 +28,7 @@ const slice = createSlice({ if (isEmpty(state.assetIds)) { state.assetIds = payload.assetIds; } else { - state.assetIds = [...state.assetIds, ...payload.assetIds]; + state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])]; } }, setSortedAssetIds: (state, { payload }) => { diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx index bbcb3ab7e1..ffea226564 100644 --- a/src/files-and-videos/generic/DeleteConfirmationModal.jsx +++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -7,6 +7,7 @@ import { AlertModal, Button, Collapsible, + DataTableContext, Hyperlink, Truncate, } from '@openedx/paragon'; @@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({ // injected intl, }) => { + const { clearSelection } = useContext(DataTableContext); + + const handleConfirmDeletion = () => { + handleBulkDelete(); + clearSelection(); + }; + const firstSelectedRow = selectedRows[0]?.original; let activeContentRows = []; if (Array.isArray(selectedRows)) { @@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({ - diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index ded6884a83..219148fd7e 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -273,6 +273,16 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + + {!isEmpty(selectedRows) && ( @@ -286,15 +296,7 @@ const FileTable = ({ sidebar={infoModalSidebar} /> )} - +
); }; diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index 3e813e6c4c..0663ea8498 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -26,13 +26,18 @@ const TableActions = ({ intl, }) => { const [isSortOpen, openSort, closeSort] = useToggle(false); - const { state } = useContext(DataTableContext); + const { state, clearSelection } = useContext(DataTableContext); // This useEffect saves DataTable state so it can persist after table re-renders due to data reload. useEffect(() => { setInitialState(state); }, [state]); + const handleOpenFileSelector = () => { + fileInputControl.click(); + clearSelection(); + }; + return ( <> diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx new file mode 100644 index 0000000000..d97f3b2bcb --- /dev/null +++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMocks, render } from '../../../testUtils'; +import TableActions from './TableActions'; +import messages from '../messages'; + +const defaultProps = { + selectedFlatRows: [], + fileInputControl: { click: jest.fn() }, + handleOpenDeleteConfirmation: jest.fn(), + handleBulkDownload: jest.fn(), + encodingsDownloadUrl: null, + handleSort: jest.fn(), + fileType: 'video', + setInitialState: jest.fn(), + intl: { + formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''), + }, +}; + +const mockColumns = [ + { + id: 'wrapperType', + Header: 'Type', + accessor: 'wrapperType', + filter: 'includes', + }, +]; + +const renderWithContext = (props = {}, contextOverrides = {}) => { + const contextValue = { + state: { + selectedRowIds: {}, + filters: [], + ...contextOverrides.state, + }, + clearSelection: jest.fn(), + gotoPage: jest.fn(), + setAllFilters: jest.fn(), + columns: mockColumns, + ...contextOverrides, + }; + + return render( + + + , + ); +}; + +describe('TableActions', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('renders buttons and dropdown', () => { + renderWithContext(); + + expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument(); + }); + + test('disables bulk and delete actions if no rows selected', () => { + renderWithContext(); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + + const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage); + const deleteButton = screen.getByTestId('open-delete-confirmation-button'); + + expect(downloadOption).toHaveAttribute('aria-disabled', 'true'); + expect(downloadOption).toHaveClass('disabled'); + + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + expect(deleteButton).toHaveClass('disabled'); + }); + + test('enables bulk and delete actions when rows are selected', () => { + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled(); + expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled(); + }); + + test('calls file input click and clears selection when add button clicked', () => { + const mockClick = jest.fn(); + const mockClear = jest.fn(); + + renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear); + fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })); + expect(mockClick).toHaveBeenCalled(); + }); + + test('opens sort modal when sort button clicked', () => { + renderWithContext(); + fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + test('calls handleBulkDownload when selected and clicked', () => { + const handleBulkDownload = jest.fn(); + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + handleBulkDownload, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage)); + expect(handleBulkDownload).toHaveBeenCalled(); + }); + + test('calls handleOpenDeleteConfirmation when clicked', () => { + const handleOpenDeleteConfirmation = jest.fn(); + const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }]; + renderWithContext({ + selectedFlatRows, + handleOpenDeleteConfirmation, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows); + }); + + test('shows encoding download link when provided', () => { + const encodingsDownloadUrl = '/some/path/to/encoding.zip'; + renderWithContext({ encodingsDownloadUrl }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl)); + }); +}); From 2973614e3b130fdb38d44ea04c2564b030d948df Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:35:58 +0500 Subject: [PATCH 33/37] fix: loading unit page directly from link after logging in in Teak (#2246) This is a simple version of the fix for Teak; on master it was fixed with https://github.com/openedx/frontend-app-authoring/pull/1867 --- src/course-unit/CourseUnit.jsx | 2 ++ src/course-unit/course-sequence/hooks.js | 13 ++++++---- .../SequenceNavigation.jsx | 4 +-- src/course-unit/data/selectors.js | 2 +- src/course-unit/hooks.jsx | 3 +++ src/course-unit/sidebar/utils.js | 2 +- .../xblock-container-iframe/index.tsx | 26 ++++++++++++++++++- .../xblock-container-iframe/types.ts | 5 ++++ 8 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 10a9ad25b0..0054a77e24 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -41,6 +41,7 @@ const CourseUnit = ({ courseId }) => { courseUnit, isLoading, sequenceId, + courseUnitLoadingStatus, unitTitle, unitCategory, errorMessage, @@ -210,6 +211,7 @@ const CourseUnit = ({ courseId }) => { courseId={courseId} blockId={blockId} isUnitVerticalType={isUnitVerticalType} + courseUnitLoadingStatus={courseUnitLoadingStatus} unitXBlockActions={unitXBlockActions} courseVerticalChildren={courseVerticalChildren.children} handleConfigureSubmit={handleConfigureSubmit} diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index 28035e1afd..cb541f1ab8 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre const isLastUnit = !nextUrl; const sequenceIds = useSelector(getSequenceIds); const sequenceIndex = sequenceIds.indexOf(currentSequenceId); - const unitIndex = sequence.unitIds.indexOf(currentUnitId); + let unitIndex = sequence?.unitIds.indexOf(currentUnitId); const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null; - + if (!unitIndex) { + // Handle case where unitIndex is not found + unitIndex = 0; + } let nextLink; const nextIndex = unitIndex + 1; - if (nextIndex < sequence.unitIds.length) { - const nextUnitId = sequence.unitIds[nextIndex]; + if (nextIndex < sequence?.unitIds.length) { + const nextUnitId = sequence?.unitIds[nextIndex]; nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`; } else if (nextSequenceId) { const pathToNextUnit = decodeURIComponent(nextUrl); @@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre const previousIndex = unitIndex - 1; if (previousIndex >= 0) { - const previousUnitId = sequence.unitIds[previousIndex]; + const previousUnitId = sequence?.unitIds[previousIndex]; previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`; } else if (previousSequenceId) { const pathToPreviousUnit = decodeURIComponent(prevUrl); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx index 0fa15fa29e..0af7ef63bf 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -35,7 +35,7 @@ const SequenceNavigation = ({ const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; const renderUnitButtons = () => { - if (sequence.unitIds?.length === 0 || unitId === null) { + if (sequence?.unitIds?.length === 0 || unitId === null) { return (
); @@ -43,7 +43,7 @@ const SequenceNavigation = ({ return ( state.courseUnit.courseVerti export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo; export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus; export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams; -const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; +export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus; export const getIsLoading = createSelector( [getLoadingStatuses], loadingStatus => Object.values(loadingStatus) diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index fc8fe092eb..7ab5eb5918 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -35,6 +35,7 @@ import { getSavingStatus, getSequenceStatus, getStaticFileNotices, + getLoadingStatuses, } from './data/selectors'; import { changeEditTitleFormOpen, @@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false); const courseUnit = useSelector(getCourseUnitData); + const courseUnitLoadingStatus = useSelector(getLoadingStatuses); const savingStatus = useSelector(getSavingStatus); const isLoading = useSelector(getIsLoading); const errorMessage = useSelector(getErrorMessage); @@ -218,6 +220,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { return { sequenceId, courseUnit, + courseUnitLoadingStatus, unitTitle, unitCategory, errorMessage, diff --git a/src/course-unit/sidebar/utils.js b/src/course-unit/sidebar/utils.js index af3263861f..390d9a3160 100644 --- a/src/course-unit/sidebar/utils.js +++ b/src/course-unit/sidebar/utils.js @@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => { * @param {string} id - The course unit ID. * @returns {string} The clear course unit ID extracted from the provided data. */ -export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1]; +export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1]; diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index 48be568b27..ac6fc92933 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -37,9 +37,16 @@ import { useIframeContent } from '../../generic/hooks/useIframeContent'; import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; import VideoSelectorPage from '../../editors/VideoSelectorPage'; import EditorPage from '../../editors/EditorPage'; +import { RequestStatus } from '../../data/constants'; const XBlockContainerIframe: FC = ({ - courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, + courseId, + blockId, + unitXBlockActions, + courseVerticalChildren, + handleConfigureSubmit, + isUnitVerticalType, + courseUnitLoadingStatus, }) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -70,6 +77,23 @@ const XBlockContainerIframe: FC = ({ setIframeRef(iframeRef); }, [setIframeRef]); + useEffect(() => { + const iframe = iframeRef?.current; + if (!iframe) { return undefined; } + + const handleIframeLoad = () => { + if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) { + window.location.reload(); + } + }; + + iframe.addEventListener('load', handleIframeLoad); + + return () => { + iframe.removeEventListener('load', handleIframeLoad); + }; + }, [iframeRef]); + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { closeXBlockEditorModal(); closeVideoSelectorModal(); diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 084577d163..e83d5f759b 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -42,6 +42,11 @@ export interface XBlockContainerIframeProps { courseId: string; blockId: string; isUnitVerticalType: boolean, + courseUnitLoadingStatus: { + fetchUnitLoadingStatus: string; + fetchVerticalChildrenLoadingStatus: string; + fetchXBlockDataLoadingStatus: string; + }; unitXBlockActions: { handleDelete: (XBlockId: string | null) => void; handleDuplicate: (XBlockId: string | null) => void; From 4bc34c268bfb5efe2e02c2f5128c862fcfd8bbec Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Wed, 30 Apr 2025 15:56:49 -0400 Subject: [PATCH 34/37] fix: pages and resources plugins not rendered (#1885) --- src/pages-and-resources/PagesAndResources.jsx | 4 +-- .../PagesAndResources.test.jsx | 34 +++++++++++++++++-- .../index.tsx | 1 - .../AdditionalCoursePluginSlot/index.tsx | 1 - 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index 2382be67a5..56ba8a63a7 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -92,7 +92,7 @@ const PagesAndResources = ({ courseId }) => { } /> - + } courseId={courseId} /> { (contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin) && ( @@ -100,7 +100,7 @@ const PagesAndResources = ({ courseId }) => {

{intl.formatMessage(messages.contentPermissions)}

- + } /> ) } diff --git a/src/pages-and-resources/PagesAndResources.test.jsx b/src/pages-and-resources/PagesAndResources.test.jsx index 180c3f8674..0e662e3f86 100644 --- a/src/pages-and-resources/PagesAndResources.test.jsx +++ b/src/pages-and-resources/PagesAndResources.test.jsx @@ -1,16 +1,39 @@ import { screen, waitFor } from '@testing-library/react'; +import { getConfig, setConfig } from '@edx/frontend-platform'; +import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework'; import { PagesAndResources } from '.'; import { render } from './utils.test'; +const mockPlugin = (identifier) => ({ + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'mock-plugin-1', + type: DIRECT_PLUGIN, + priority: 1, + RenderWidget: () =>
HELLO
, + }, + }, + ], +}); + const courseId = 'course-v1:edX+TestX+Test_Course'; describe('PagesAndResources', () => { beforeEach(() => { jest.clearAllMocks(); + setConfig({ + ...getConfig(), + pluginSlots: { + 'org.openedx.frontend.authoring.additional_course_plugin.v1': mockPlugin('additional_course_plugin'), + 'org.openedx.frontend.authoring.additional_course_content_plugin.v1': mockPlugin('additional_course_content_plugin'), + }, + }); }); - it('doesn\'t show content permissions section if relevant apps are not enabled', () => { + it('doesn\'t show content permissions section if relevant apps are not enabled', async () => { const initialState = { models: { courseApps: {}, @@ -25,8 +48,11 @@ describe('PagesAndResources', () => { { preloadedState: initialState }, ); - expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).not.toBeInTheDocument()); }); + it('show content permissions section if Learning Assistant app is enabled', async () => { const initialState = { models: { @@ -56,6 +82,8 @@ describe('PagesAndResources', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument()); }); it('show content permissions section if Xpert learning summaries app is enabled', async () => { @@ -89,5 +117,7 @@ describe('PagesAndResources', () => { await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument()); }); }); diff --git a/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx b/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx index 74a6f55b5a..c98ba0c725 100644 --- a/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx +++ b/src/plugin-slots/AdditionalCourseContentPluginSlot/index.tsx @@ -1,5 +1,4 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework/dist'; -import React from 'react'; export const AdditionalCourseContentPluginSlot = () => ( ( Date: Tue, 29 Jul 2025 16:27:24 -0600 Subject: [PATCH 35/37] docs: (backport) adding comprehensive readme documentation for plugin slots (#2340) --- .../README.md | 2 +- .../AdditionalCoursePluginSlot/README.md | 60 +++++++++++++++++- .../additional-course-plugin-slot-example.png | Bin 0 -> 332049 bytes .../README.md | 51 +++++++++++++++ .../images/additional-translation-example.png | Bin 0 -> 37095 bytes 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png create mode 100644 src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png diff --git a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md index e467dc5061..e132e646ca 100644 --- a/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md +++ b/src/plugin-slots/AdditionalCourseContentPluginSlot/README.md @@ -3,4 +3,4 @@ ### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1` ### Slot ID Aliases -* `additional_course_content_plugin` +* `additional_course_content_plugin` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/README.md b/src/plugin-slots/AdditionalCoursePluginSlot/README.md index 39bf9e8fbf..5e6c286a33 100644 --- a/src/plugin-slots/AdditionalCoursePluginSlot/README.md +++ b/src/plugin-slots/AdditionalCoursePluginSlot/README.md @@ -1,6 +1,64 @@ -# AdditionalCoursePluginSlot +# Additional Course Plugin Slot ### Slot ID: `org.openedx.frontend.authoring.additional_course_plugin.v1` ### Slot ID Aliases * `additional_course_plugin` + +## Description + +This slot is used to add a custom card on the the page & resources page. + +## Example + +The following `env.config.jsx` will add a custom card at the end of the page & resources section. + +![Screenshot of the unit sidebar surrounded by border](./images/additional-course-plugin-slot-example.png) + +```jsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Badge, Card } from '@openedx/paragon'; +import { Settings } from '@openedx/paragon/icons'; + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.additional_course_plugin.v1': { + plugins: [ + { + op: PLUGIN_OPERATIONS.Hide, + widgetId: 'default_contents', + }, + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_additional_course', + type: DIRECT_PLUGIN, + RenderWidget: () => ( + + + slot props course + + )} + actions={} + size="sm" + /> + + + Additional course from slot props description. + Or anything else. + + + + ), + }, + }, + ] + } + }, +} + +export default config; +``` \ No newline at end of file diff --git a/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png b/src/plugin-slots/AdditionalCoursePluginSlot/images/additional-course-plugin-slot-example.png new file mode 100644 index 0000000000000000000000000000000000000000..6dab83f75536790fc469426861602e011ae921eb GIT binary patch literal 332049 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Ë$esnX5C|#i*?SA^GbPXP7@! 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

wsLRp+fcd?w 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 literal 0 HcmV?d00001 diff --git a/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md b/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md index 68d4f27e7b..7d1677de5d 100644 --- a/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md +++ b/src/plugin-slots/AdditionalTranslationsComponentSlot/README.md @@ -13,3 +13,54 @@ * `additionalProps` - Object * `transcriptType` - String * `isAiTranslationsEnabled` - Boolean + + +## Description + +This slot is used to add a custom block in the **Video Transcription Settings** drawer. + +## Example + +The following `env.config.jsx` will add a custom transcript option in the Transcript Settings drawer. + +![Screenshot of the unit sidebar surrounded by border](./images/additional-translation-example.png) + +```jsx +import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; +import { Collapsible, Icon } from '@openedx/paragon'; +import { ChevronRight } from '@openedx/paragon/icons'; + +const TranslationsBlock = ({ setIsAiTranslations, courseId }) => ( +

+ setIsAiTranslations(courseId === 'anyId')} + > + + Custom transcript 💬 + + + +
+); + +const config = { + pluginSlots: { + 'org.openedx.frontend.authoring.video_transcript_additional_translations_component.v1': { + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'custom_additional_translation_id', + type: DIRECT_PLUGIN, + RenderWidget: TranslationsBlock, + }, + }, + ], + }, + }, +} + +export default config; +``` diff --git a/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png b/src/plugin-slots/AdditionalTranslationsComponentSlot/images/additional-translation-example.png new file mode 100644 index 0000000000000000000000000000000000000000..fa79a1610c052b4b43a91548335e462a34f38df7 GIT binary patch literal 37095 zcmeFZWmr{P*zZkuw;-wXqPsytB?Y7#1Vmub-QA$lt#nDpl9p6bx?5Vh7P;Pud%L&i z+2>r>`EtI!c;T9B%z4kzasS6}5b;9!DK-W<1{@q5_VZ^lYH)A}?Qn32>}V)J2q{Tj zF&rGciIueUi|5kPG%p1jhoP8`eLpZ>@Dvx1bQ6RV2*K>ez*${g204!q99WOnN0PE{_O7&E z_1&+b>11)Ykzsy&Y?;ima6`7Kp$C(8MI^gRk!vfD+sKi}G2 zD-d{YxWsO+XHm@z)7LDzEZduT+bf3i-DRWE&e~rPd7N7RV!>hIO6dC~n*NkH{LmA# z?6>c{EPygkGS_}?p`--I0$iiPA;S~EAp%$Mz&|*6ayaB)*Klz1@D%@CtHCq<{R{#e zT$mLc(%;YM0-yIkZ-HOH`tMJ~_n~m8z&8TmH~0g>@23&kKOp|TMq~%>!AYr0KYtE< zs+&2Qn}eNR+d0prAO!&d==RTaoZ#R{=Z6!rvGdo*$lUH`8 z=Irja_V@3B6Ll8`E^W=7O=#S0ZNN^#?qYPmo)8AE?}It$XnsB7Y%NBot@MIM+Ro9O zhM%32os&)+gNBAi)bW*tu$qkA-{!zKF}l~z&i29_9ByuI>~1{lc8-=DTtY%Z9Gu)7 z+}v!y6KqZ%U}qC|Hn7v9zh3hDJu>D_W{y_&&Q^9{n)~;fnA*8Gi_y{DNA%CnU+FY= zxB53Gu+!h!0y4;Pe}{vMos;8#XXb2W@qaM8zw?*buYCO#o#_3`gf*PZ9i{DTZOy^X z;{TSo=&zXmmz)2l^Dn~}R_^9D+A>yvr4x`RaRE-=zpeh~o&T++&cBv|T>p#ZA9wz+ zyw8HLnz@smjmv!zYJjbr#koZ}{&&Iu(@OhaGjT3nUJkCm&HfDkpEf%G2b({`|EG8``+S;#Bps|T#h;sbXaK$kWcJN=p!AZhBmyvqu z4*xR~eO^rcwm0s{ljQ+GPCt_V*1(ByM<7u6GXtoSO6#|9bA%opva` zJISgRyr?gwb=1A}-_J*)ze0exC+pGw8}yjd7s)w*tWCU5_IFgk!^A*Xk^dU|Z^Oql z0Y{HMV0J;zirL=(8&MIBF);Yb~5syF>E7Sp+ij9YNIB=gptFU_pBgU;dxOA-msPjgvh8x5QrJaboErKXm&e zD4H7HfTpKH#rEC5QAi>KVJyoKg@20rneh$U76R9QV%qDG?4!ki6o}|e-elMfAzKxaQ`hhqW1rkFH!s2RAK$If3x)=&>sPU5Zm-egXoF# zNBD2Kz4&h}ynu@{%ATeBqdi~%_56QR^~M(GN{WgvAFlq{m{e)(7qA;bDZs5_^N!cq zo!R@10`pEww<`xQsFwTI%G1txl{?1L)v}r)f{iq%b52#A4u3_!WYC=u`*B-_86!gNk(ay+-?(qy~3U_A?q&VXPIOX+@8^;^Sk# z*)6ABUy2?YLoVw@^jsx2g=#|iv~no9^emvuz<#jt4CQ&g{=z6w|cQ1x{-h{CFKmGKy zEbezx8++g087E2-D|R+-KblJVYI3ePxU${bDU5QdMRnR`PN_4)Nz{!+MNE1qA1;e_ zTCDiDC4qZrI~QXnWvu8*+2Qv#Y7YyuZ=>X_9830Q+U#@vj*8Z0H>dTbbZv^JIJEDJ1M?3b=xegEPubH zT@ZizVA-rQ!ZO2X_Hpn5jf~w4dL@w=Z8<@wi0x+w!5g!tBx2P$Ao5w)OycCf$Bz%O zNZ{u6IFn%&Dq>%!vAvK9>iR=AVdkV2ZrbLKYK4$oiIXpt^P?Q>eY*X&7|Xh@>4{1I z+77{1G**15FZE3XYaY;gpJ4R|bFPfVO8o3JSv#BhYFWH~RnxHOxrnVLZWoiY097tAXU~nZq`Eq)kKtgXxRq z`sc_ORVGUd^;brjSrMH0QMuT)`TNh|^)IMu@1z;+xAT)j8`^I7OhSk?(b8?IpF{WE zc43{=!n`WqAKPE=HH2ll&N7$Pj>?;~oK0dredr(q9rnx0DzEi;i%IadU7N7|_`0gI zRk%0?%l;b3h0?4z3q=uy%K+o-lk}a8Q}}YH1D%VHgo0<|>Yc80wgTItPbpnqPCE^Y zG1|HXA*ms>T`wGTRQI!|gZq+}&g&a4Y4{={DIRlXAst>W_*Mil^5K|7lWx&mJWgtOmDKt%tE6l6^7tm6uherHE>(dVDYs=#x?8b zkeHDu5ofC{=wvuN$%Zwb5(zB2=ORE7VnrTvQX!$aoOcnb)bkd^t|p_%Si6w3#MF=U z^bZLap&*XxuXM6a1q=0< z>=2rp6cGETQHDp94&9t^E5OubhrW72+D$)=hMjl$PtJ-6C_iOF{dM?$jWD zaz+v%iH^BQk2yIJ6dm8io#<&DQG6tdFrIy;x67|1cjk9DZQ4f9iH*K|ci1D)%BAo4 zjXqu%wxiW13~Gh0#@DN?hhlOxb9&#bQFDl{D;ORVARofMKJ@tUZUv}8)*rR&NqV6k z8>xl%M)U3`g92f=zK1~$b?eui)OYszaEqoT_ym<+tm=Ei6!YlVj=%I9gE*~Wcx)`>T>uW}{37EkmLkda!?>nf1Dz-fl()yW}t zz9;G149WB_o*8HRoTJBR@3LKIT{LfooD54yfSM0G*yar0`6Km&wcigg#ay`$_e|8x z=WLkXD5$2-5%Ea7S;J?2go<4j5xS2=84~#(MHvNKSG4fo0Ck7uG{~o=?XjNNX;U-E zzoXIM`$*C_Nyh?x3ECsj(R#g4u@C&m$;I_fCweo}Z4tjHQv+Blg-R`_#1G*JEV)`L zj6lSr^-Nb|8^CQwH9EDa^#@gJB!jlO%`aHtZ$1zEu+MuPv?Pk+3jKJiSVlxy+e_sY z>{>w?F=6#yQXUS!=4z+3FkL@MQVS_b5B#~k7g*9n3j~Bwdw?E#bm*|flr=5cDVV)a z{kf083)i*6n}Z%sOmG#0~bl-8C{fpRvRRs?bQ=H~_f=(?}4y zjf=kSvgC7Nj_4rBNTReeqXK2`>q_fhrs;c?c|I%wyP2_G(6P*{v+Gh3zkH=>r)}b4 z-j0VDNuvAuqhwyJQ~_g_JKgTnS?-}=jkT3NOWA*OSoWvu4 zD-kU5=G~-ks*f7oM`THrZkACcUN@&w#MR!O&#CafAoV5Zaj&!e0aUNh2%4=1^l~)0 zHDTSZ87KQSR&1JcY!8-7$oi5W?3OT$(sjCv+`5d5Sw(lMN6E&t?e;t!vR#ml(H}t} zQoLpsC&usav#MA5g@tD7ny?DMIXz529?Z7s#;qtJY#glzG8wA?7{R6jdsW zL&Cdd5wPnvn6;jTr{r3Jpu_PuMipMUNv)(M_qVBnM_q)BRNjYQc(3!!uZoH|8fGlg ze@=(Gr%anC8!%*Auf+&}lVtTG`(AfNfvE^dXEWp0$OBswOgaUeS8oseU@~SKmD)e@ z4g^}Zl?Jb57G;+b5+~DLPt!veU@@rQB0`7HWI8uJ)w1(!^M;|=N%U8Iwq9uhZ3721 zV*GcZ_oJskLqpTz*v)v$QTt@sENSauh=tI$14wNhyQ&L(jO_Ae}!*%Eo zcsJM(;xYu=v-1;StKZ{>*|{!yY<3)z;||R5&ES;`IzrCPT1F3Cr?l4sJXV30X?N{+ zNL=CG-Bl=lRvd93;h<9^O+X`_`=#zdR>}pj-5XyV*s+yMvFGsFynEAa+Ft%1XOT1p z#_}Y%aV3-j!wzX)|9sKwAfo5>@#yW*#sdZKo#H|U@#~r4Bq}3k#Hcy)AWH^3B-vsE z%FjA{2tVDN7egg>Wy})1My%52$Z7L1kp0T6X9Uaz2_51$Xg6Yo@p~bg)=#WzZjI3Rm(3YzEZsc zYLmk7dUlE=6oFM(d_itT=7lkyj?Sva1-NOCq<+D5u3B2YL7+=z`gLwh#}`zUEYoV< zF_SfZp|P8n^A58fs58Q+E_YFn(Kke$z3SA2KJ z+iLV<&NV?F%6ZM(mp&NnI#ReO|*vYn5*Nl{m$UE?7iHGo%DLq7Z5on&X%My2D3k7X`8GZG)R zVeeM7`EGFN*HRyi*-tRdP8{)@uDEtKnn-cPV9p&qZxXCLVi6#uFnIM1$9zM&{PTmv zf*cq{xl}+vX$wQ}%B~UY>Ts&RNX(MBIf1sm5p_HkAMs%K;+KR2(r4fR7*4nSLLflR9qD; z6|roRkWPAC>Hm$15&00$Gc3R%M#~M6`7*^U){5fN6pv9D81-x5`K)v-vpg)IYt*n5 z@EaX)^XLFWWIKjdVjt4^da@B!r86#_iB!)MiO7ialWlq~c*Gz$OBr%knAvBx^sl+; z=N(B87ApDEbkTnT6UiqEa_fP}3qVnD96eX&cYfsS;s3@hD^x`7HM9=(F8L+>VtDkm zw6ezag1{9Ld!zSpx-uv?NclE$xfx3N@;0)pT~-mrrBy0Hy_5+r==I@;ewykor>v6< z`vsRNzNd-;C^ZRL1uGKCIxgs?bpzb}uO<`Xm4%t{RzHFGQ0iV);Psy;+iq@De?g&m zC6HWN(JE|=Um`!H@1!*CJf@T$b~xJ#I?^TVH;4|KBikZvJ;^)>?t#ni04dT()p0Nw z?j6*QDLd31QfT2AX0kVK<;)Bo{Hz(q>7{hjU#YUOfj+2jSxGWysOL~cNjZypupF9N zSyBO6jnr{Q%t^YryJ+JOqg;)3^;sp%{u(Q^S36d%dQwL7f&x(+^JEm)YOCH*ZW6Z! zR_v?+I}wo*{RH?6I>A9kcgM*yXL=TFp=M^H(hLPDAL9fNRPhelV0ZcZ?4rg6x3_&- zt=wx7*WUmSHihlcc&bu46QB1U&r}lRKxf!flVpUx9CyuYuWo9nLrDA%x)#tNW6m?^ zu)lP>?=}M?bMJB9`WV*JJ!&^Adwscd4zxaDpitAv#fS27zsuwb0rPWO&DIJ=C+vF< z&GI48ARvb?Ahu|Wi>({X zH8L6ZgTArP*$`+wJ(!cS{{)`ZKpY@%Nya8K@t=tiSkxw8|3FKpfSSa6_pKr8(9as{ zJbZn#P2w;BxAw<&UhHO;7g&KxlZ-NpL&U%?{4hy0x<~n<*BqsW*E>TmHii{j!O?{3 zxFE>U7wC-1RJ~PPYSJJ9Umh~1$w#nE>fkZ(ckCb!vhiuON5oOtr()q!_ElNqg|rO1 zLnD>%m3V^y=&qc(^gF?ug9)|J@}qj;ncY3Zp>O{Ee)5vVYaNR zO84fOu4`k4VZ5xz%=uXMT`jL)r))7uuiocH&~g#al*%I;!~RPsC4mrwt-9~$6@rO@ z&Z9kFQW?{4j3^}a{mHOf{0TavWC+!xogc%}NHNtXwPu+d{VwQ=^Z_B7-g^C#l~Opm zxn;c7GWz=)4}+L;DTi;21yiCOM-k?saq~(YR~@_sU7tV-SnM7XrG|V~0`WEppD}G3 z7F=ii`8zl#5+VvufIuk$*X3sXBIil9PnEI6bX#Me@&1@1Vv~2WBv#)~YdbK|&&igp8sT;DY6B<{3#F-Fa2@;_j$97ZthmAWZKy(w)C1p7A<;1T> z;z;<@BiKwk67xWOi>|?P8H?2msR}ZWh+Zu>j6LrdECg{u6VZcRr%l2RffZ@(dL>xK zRY6YcF=r6ky#2`@n}kwAwJZL35=!CG&_#` zi882U_XU|$F^XBAy++f)ej+CRHrSjMb8gbzr;pZFy*9i~%w7_8d00O&1sQYETyq(W zLamWmnC{d0)6ZMIpMb8EMI!>@s-CZvDAIQg_0gcG%IZ_Qf-`WT#8=8)0L7vmDI;?R zromFpH@iqvaeI%x2To)Qyn>qW+(Ll$Jgt5I-mvH^oSGrkA95oU!^HQs?o-muv1#Cx zxUO3um&L+gHMrny4Zk0#$&o~FXvE_l6pm`XS`fujD^@!oPsGE$?pPR+ZUC$xw6~2x z;MZN7-D+CSYhcFnxt5(?jOpU+PsXpk7-E{FPEaobrzxlx^v3+{D;V?f?Nnlb; z@9t-q@Nyxz3eH8ncjI}JcDruRphpQoU^*J%-YDRw>v=v)CH}bdMnBlev|l@f6yMpPUT}#*fkFos4#2 znZ9eVdTS!OhOdJN_|b=%?^Xqt&PCH*ZPWX1GfO`fmuUhUAYwZ7i|XsK~Y)kGAjJf}JLM6UP~z^T|EDcyBxVqxg!_ zjliz)jjN2foHy!2g zh%r|xbL^yX-Gb1AvYzcsZ8M5^)R)+Yv>1rKwhsPZf%V0r=-kWJB)hEIPGpb}^v97&4_^)e zCs~A5vQ9b1r!Ho^fZ!{-_B+v&0d-H!J`!iIyND;=a zhTZED)fOmpD*Awqk$=4~LLI)e7Av{FG~_39bT0J(3FL2#(p5%G~1{)oGg3(wpKr zod9$fS;h6!pb9?m-du9z51AZ%6-oFA=2mi;%e98UXrXTrx=r`4-b|RCaJT_<6k7O3 z^Yc?Gk346&=4$S!`jczMQOp$r6+l+E-~H5Jf%|n}QR|GMjLS;t(c})HtZbtau=yZ4 zg;WdY7E>nNx!2IpgQys1Q5{v?l~9nIK&JT80-r8eTeuk6w<(x&wev$_kBxnZ((`_= zlk}x|?j4cAcITq-b?)rxp6D0(0z0a8IXsD6xZ>u7qIg%OZ|un&lAE6I(b?9rT2BKW z-Af&iB?D7&q-203L#+MIK|i<8uz#s5V$WzjVQA$}jEtwv2)CW|PC^M}6sKStJTOWc zS$3*eVQ`j2HyqF)EsHN=y3_5V)7@4ZxW<9pqLwkB$gm4S@cpzMy~LvW94rCCn84} zLI+_H>!pfVY8)IoITLLI*Rjawl^}1 z+2fcW9GOnH#m_`N@$zWGsE54Z zt_n<=l*rnPax7Ion8?HI^7krugNMV@WdN)l2EP~&1k228mC zw30ol;wCCtp82w@qBR=v(cQk4#`)Iq^-IXf@~ot{cCeGqj=8^~j!x%vEScmu7v|(q zFEzEY_~j3qz8%|!h5BP>^Su%RaPLDWMgDGX@gzkneZEf)Bj3UnV4_Ux)JUdchloqY zLDV>kT2E3ZKqXMl9b9QY$ z8yH;&LO7=x&dX@F({4*Xtkyx$E~Cr`8Nne&VRCU_}+i<`K)(|z3iT^!F|C90P-@;S{(RajuA{Rbly|f|?7hJgfEzK@(>%M+t0W6EgI@&1QjF5B zAY3p(o>ucJu@2FDZfjG}y*|qveW1UtC7;S{)CaxUl%S3pXq{6U=s={+PGPEH8GSAa zmh(9|{1Sw;h98>hzAUj@R)eAPB?!^ZdQ0yv%vj3uEl7OXJVH3GBuZT;b3t4T4b;4h zu(C0?Z^M5MbPTc*OqoQek^yDTx_~CAys)>+C+MnR&nj@Rh=HvgasL5`LL+I=y-}5< zZTua@=)IP&z0hITHSe*RncPApwieC9(tU8f1iO1)QM+_j?uINL^bD2K`NevBqf~~O zT*4R0PHqJ|5%`SB20hHfm!}eUjoK=6^j&HqM^TB*1Ll^W*ORQ>A~)`Gq%0)#IL3tSq6y{jhfj18^WfpIiRrt) zr!d1&p6O)5z^>(tyKT~U$N`XC&*Oc59IskOaonaao)*OJ_Rv7MFXD5aR8D63UTuqY z-wq~Uv>fXd%?d_L0J?hbcXhSs-E)*el_j*GB z1$)Jba21l1=Z)@DTRi=)z{(#QR)w|^m#YFvM*5H3Iho|6+uII1Fuh1B?WOf&uS!*) zh~qEhjCH}$mF-#;HRm(~8#N!*Ij<7~_OE3BE zwKo)n3;{JhZ)(nM%wBqIh8HfWeuft*!5@Tt^re@4QRzu7!D1Y@=W{4EA#vVPSf1gK zEfu=6yz!;EF6z&X1vp{0!W{^>+YV$6j4#eL;hV!X5pz#hnwnr1xi8lEU5e6N>*Eif z0b2rk+Yd_4*LhYj2b9eE*%}58-jr7~ANQpahZa9MieOW?JlU8U^VYZ?U5iZG1eBJX z!GLcf;l^KfRlLt<=?&kRvYAdY%`?q96z*xK*)?t#C_b(yoJ(k%GV}_W12?gqNVA)1 zxw6%ks?4@>XPMK^jy7*|(?!2KRfK!NM~gD4NJWNrWuytK)t`-p>q0gMynSUiY#Wd1 z+qk0DGk&lX+P-VW^k?xysWw3O=T;R4gpWE~)}X@h`i}NablNxdP}`0@!BaA!X1f!0YlRBZFqcZWt++NjsBP=+ri1 z)z05ydgU~5!jUy3D-%o|qcdjUg`*pud2Q!`;@-7aqLm2uIW!S z?6wmfd>LW9lgg0NZt-$VF~(0Z1}}}!q!U}<;e_yHt0ACbyu-z1i~SoZHTQ zKN-^1-TkHm@VNwC>=G6}Dyoi;SG)=zPU;`Hd^9>r&vJWfp1FIvc{7*qg4Zt0SRK1i9E7I%7Q@?Fi22U9VQ)o6CvbpT_QHR`$@^+>E zMA4xJg~-^GSpX8M+y#g}S%q^4 z+R6i{@v6#Zn+RNem7mf9a3J7;(g&MmikuWwmnV(+N68+g{!v{!r4@$4h>vVb952#F zY|n29$oK=Wr*uB86G7o|N3rQh7E7Tqm;Lj!t9OQNQL|~5qt-K>eT8sTBs%@~Tiu+2 zNQc?%3Xl*^E=({3BzW01`1<)nG_9mKPG^XC>SIm=bkI=_a;I)-`S;NqK%6qSL}m4; znyrAw6h4&ud>P>PO9M5ak^Agc_A8a}gzlXbi+JJkqhMD7P={rx<_E|990xPWjRu$d$&w3bpxZ{a&Rbm3 z>9X?=D4l$m55I&oR~#dp3{rwGw;w#x9-Cy@Mi}YqhIRtyuQ&VzvB890gEbPlZOZ|iyv(Wvey2rXT?fAOwSLp?xvjm>{OHsaO$8*`D z-(>>e3}Xy*QDFC7=P?>ey9pb6`eRFs(jUc-Ur-?QW;nY-Pw^QW#xH>OPIYyJ3}&{G z(L654eYvadb%1w1?GS}uY~sR4)IQ-o&-xGc(nwdi!O#?%Zr4;pKYR~3R2yC-UMBfC zDG(;zQzCr_RCCp~30eT#wZ2#d{>@5_$F?(yLpt+>+gz^W6lXQ3uw`wT-<&WtsKAT% zwU{mZCX$LM1F$Cz>3`O)_pqH=Na`@t| zQT|!&Uf?ma3LVSekI7}VJ1VpZKJ=RoW1Oe~|9XsRR*qsf1&H`3-!AAkH^YWz(|kw1 za*{poqh_#67YMBUY;$Z~lrWYX&oj>cp;SnaT-`VII=)bQ9OO091xC@aC<@!7*nR+y zb7jBAv@LN`gPZz%QP%Vu^r@q%?B`D>(hZC#l2kMRead}b-gw?qrhNGtcO18$!?xJV zk>y9@#fP(h7#b3!%CsSg;rU-AjQeT?qu$O#woljl^&0)>#rjfEwK%$!et!XP-`$_* zZ>z@ALkmfEAyLaK!8L? z&;7$1w!i%cHhKtd-5cuTp$&b0+xSdoL_tTk9LI){?PRnYag1WhHngG8dg-nL0V4J& z?r;1N=zr4m_5;r?!yc&!vWlJ_Je|#Aj5NXNdh$@cmgteAStQ*>pEbCQsQCzb-w~#* zH{F88T<7V)m7I`CGAOyxtpEv4Ark+jM;^?h2S{d}jn>V=Hjtq=CK04; z)3*3#4$TPepvDoo-#8m5C7Qll=cg5*-{CgQ-)$ZiHtl0y%)9wPuO;q?sXU=0hd<(B z-|z;6cR?oQLI5=|e0@`S7>F56Pinw)zd$s(G*7}{WrH1W`xo0%WTNG=(c%7Evbj=l z@TK8#rQEf=8x*TV;@{r+Ue5}<^rK3gpMUhkOzH^7s-m`2bi?%N7R zv-)mQ23x*A5;ykHO$peHDE%%2`T!cj{W6Hb zSLg!RHJ`fQ2M@;egLd-duw<$K)pj{wBGi$#hd%|60$ZxqdM^NtPzDODXHtC@0l?5a zY06Sef#Yrcvasm#kjMcRuIQnp>uHA5(1Uxf%q3;4Q!YUAXOng^07lw5I20|xfE5$M zn(IX#3THjU|!p1){P%fc`O&q@Qd^(ZyVrxI2n70k-gQTeOvi0WIz@ zOyY)i*^%@5XU$Xt>eHf)u!oM%pn$%TJh275*B7!Jh$&a(s+mt201zMC-T(|`OLboE z)>8UebO4#k5Kw%EH<-@>p{R01j)HH>;Md3*aEDIg+Xmope3)tbbdY+46s=m3eTW&f z4S6n4!H+P;_TJk~z}cu<%d{A8?7!lvPVJuij-HEM||xUdGT{1cq`vOgk2#8RBk zPqTZ+&E~VA1+e4j&*zwGo`gPU+hAaEbPH^MFNtob_+6N6(A_ zo&wif@mGW66h|^VE74MVGvG}6rOw&E1rPxcC+B!69lG%2yX+dX*L5sX>AinEqDF7t zQ6dcT3nw@EclcP7Grp%W0Yz>g&w@5E*XS@W)2#Jl63KD}K(Mhb1e&|8Cu)IbH0!DX z7dZ5u062l)5S(Xf1?tuEuFI+w;Ogk-U8W3X3IJ%4v3Y~9;#*Qh%lYhKP4+Fw>639M zfOO$6UQISwT#rh`iJfahN8)@FGEoJhH^mM-bi!+hHMMC$EvKW~9}U=y{BF-?tl#XA zNNcui$#P&=f2z}*tJJuyXEqv12R9afqow3M`}!y@Sb_a>o`)bVHNYCWI<+i$ik^O# z4#0D~$2U6xrICcn@8&3)Iq5FB?PmSK?#4GqOsvLxjdgy$aa5i)1Q4tQy7ol%k0ej$ zH^l{-?~ynTk8cVcETv`d#U%N7G(e)z<)DC~$CV$!01N5IPxDqPAVKzZt1{uon{oh9 z0P%CU&~dDf^Z>}onGR)CU!-lswJAk0g`K4P$rrd$p^cPBz)pntjNW@c0QFpH+%1pN zslLluGVR#g0-%GhnhpINyjDH)T23#4Qv0bbbCQ0SWAONjOG$r8 z#wH?SsV^srom?w@Nk#1J5-pMt#}Y(3_1C1gY=xkTbWqhx#rCYAl-|7n zlU=Y4|JpZ)YwOh-2~BQeAPr5ljv_=L15kk|qvBKIDEL}CxM*3J7?rSkq_*B09tM&I z-vhxkS!b3njgRS$RI*qPuA$z zhllhF>qN=(b+jKWojbVc%>2 z{U2}G9X$Ht;Gf|>`9L_8g8Cr-*UT@fnWsEE{#@vmYV5gKrT-{D6=*j99%C^n%yfOzQ};=js&wmm@uF4jNWn0e z3X#DN*kBK?&|Ks{HATU4hm{*~6hlm16twt>>St8wp+#L6JlIYps7ZpDLH4kJl z;IV!2pc)&b%yDJM^HB9qES#zZnEVhz7165H%<5!q(})evMt``gA-C4Cd0xP=wyfx# zd5dAi{^9gY_}U{gDjiAF!lJm-Qa_J(=K z0G&I$kImw75iX!}#jO{KYNZS$x+s{-1Yh(J^dF1{*8wRSwrRbxq~1pfU*mm9oiu#E z$+k<4M8+-v!RvAc)TLL~4l~aP$MF0PgQyh+%=;^R&q`}{W2{4`Zfn^J6gXOVy>3;W zwN;-^RQT4Jlg_8m;vX%Qh9sa?Gd;0703?~L?gLqy6MxbR2U9AvOWSU1)7HxkYxWc4 zcyVo0mkoG6hAROE-=M;5Ur+tPR*&>Ry%9y8K_Yt7+JeY)%NT@=yz7A2|XzX8o&f zRDyoJD;6qPFCwleXL=p`Vv!y&4z#_9Wya|IIAIHug*GN4n)B?2Ao_n zJI;P^PbNa&R_4gj23cbyeFLj$h~Yio2KkBlXE^pTvsG*fk@`rA;fX)iN5`x3_?Wsk zaQjhZtAh9FMez5i4l0K*R@4p&22e~fR5lu4MS$9f{75dlD>zE(3NF}%$1Z<;1>q>o z%}Q!(4_F2%eGFU9+uJK(@7Xg3^k0gvGKBBGY3kV~>I9B0A>Kg%-b!mDmvSyj0hKOw z2?0USkQ%Cg@_~R5U#D*sZu1tlax**C%5Q-^XH%opPK?~}7OE$S=hji66AqlD@JA|u z57=f8%+_cS?=dAN)NJ!5(5&RGBW%a&`trV;CjHccy1!kCRtXE~iGRUr@@?6g^|w1$ z^tOH`UO^HCn!B1}sJRHDZ>-q)G>HQ1K3y^A8pQj}RKd+rr>>n=-2GB1HUELsSJYC& zoow>m5V#wFAf?i(Iq>d!o0JLDlXBna=4gVVzKi6kH+i5;cvoiCag{~d_VcNG>k78r z>-tBqH(Br&Rx{z=`t--E$<<`15wfC6OTgqfI`O<;M)J#n&)b@^PxZ5JQqM$SUds

<0@vU7d&=tIh>Jq*L$+7sFeZJwZ4DGcJu`8$kYgDQw2&aR%Sbv?L{4TPV@rKbq z*E~*nh0jGIg2F6q7JV5&NI$8GE|=gDt|D6-rTTHt%?vweY_CRffcJY=eV{90q>*+w zPj1#xh4MAWETq0YY2v33!*QuH5F?X019mGaE@?}obozQl6b*1Aj}H&uQs*U7_14Z< z$uHneXOa3r1CsMqe0J0a`IqJ26;J~Z`1TwA6fTzvtC<<#0065|s?JONRB!tjCIwNO zmUT8e=DCG2S6>M%ERe#_-ko#?lv!*R+VFk?RhrRV^Y6ACA{D<>hw+$pXW}nF1#X9E z5E(KrEP;hM^zVStRnwxFzyM;AH^~fcmrZIqMfzFc$rm>rXEOcuDNQ#SMig9*FH1pv5QUcuKTjsjR%>0~3*u0B=Oi@fA4fiH z8z~8kLne6omwaNw<3?#z*fY~gy6m~CHj&OWXwSDG@2tu1kvO=9OnxXk<}BT0*}%~ zth`HMgZ)s4LUUIZ|IayCmpX#o7E=zZ4grjlXpRQV`NUC;% z7yz?VyE84PK{v;(o(L(LTwHH39z(EZ1@j`O*&Pe~!#L(^x%NEfPnL~hZTc<`Yl_d( zv%HVL`9_hY-Y8D{e|D`&66Bg z5@U;>@{EsY9&2EAPR!QrO^r%fKU^3-?Yayna^q!~pgFf|>T)n=He#r7MGu%{KVlK< zf$cpVZx9)Nx!>ECSZQh1aU4nf{xzPqg9F!x1#d!Tvl2p*MMNAy8p0ZFCO?4JwAB1I zE+9IIzGM)r+w6Lx`MTf*)fu4WGfrvUkqARggi*DTUEPo|y2snUJ5WckmoSUQZVT-ck%H8o&^03wx z=hf8teD4R&l!G+iG{%3?+~)nh-RfXyQR}yO@gShTQ*xyAwmEM9WsM0Ql`6gID|CIG zaN+?h*J3gT8_JI(DeVEShdbiB^(VBW1&!F58FAJbmEL=5KvH3vv+EVJaeiD$hT<|y z%PT1`bSns-_jW|3l()(K_!h9OEO~T&O8f$-=CWGtJJXJi_-rbn{n9H2Ijc0XkyyNK zKCV*DG-2X)jo$U#XL*5QVp9-~wUbocb2Nn>8FblIQWA}ob_*odvOI0pi0y$_)LgW# z>L1hP1lwB}aT=p~NquM@Sl@-W5UMMW%u6D3?k%aiT;WZx3L zVVC%b#&h&*#jWUu5V>So?hh1QN$dnjDSXDeE7;l3_rdN9@gGD%@mJ5a-;TlCD$b-+ z7sYdA-$Fx@==&6(9+*9E0#3&C;|$k7613l2{^+lAcjjci0l)t+80J`}Y&e}i_M^va z)NbCifSZez!uOjuUaolrry<%3Z~ko!z+vY#RpS}4Q8=$%q+@Ko6XhYIg~3Gkv(T)Z+)y%nBFgs9KIyg;Gg}hf_p%+`oD9+hW zf9U##&anjLZe=$dIT{@U$|<}5GM{&VG$(933iaJ_#XzYk#UtMnLPe|`9CP#KtS<>& zipc}0eq$xBo5*V&m>lz7(<8wia{P%QDUd2l7@n zF~(b>&BVC5`!i!b-5YLKa$zwJ!h0L=Fummfc1MejWV2Wy2ES)cnMI(H%;1>dM4n&c zSve9>TfA3m5(1fmuAwL$Qe_||aMUNh%w8L#>^slzzXcFkNd_?G%JCKjs~i2}bwSUx z-;D+Ehf5;u0$%U94N5BN()z3TjZ}^g=?5Bt;Jz`vL8$!a{?!-2? zZ9eK@^C&b$7QiluR5sHY1Dl?3pE;(5Cww6qx=g^02yNq4<%Ud}!QL}UJfdz37*mlD z`yq|j;R_J=HbR4dTQ^g^ANi|S$$WTCkmC?{GFR?rEr7<(AN~zh{3t>t_Bf@)}o;&>-~|}p=T}QY6|A;LZUzLve*sRWY5f~ab;v9?&$M$ zW-W4F9nv1P1H|_nA0x^0+=hp4^9}*HCTs5gAEL)ulzq$EJf;Zi(|?^mCq)1zc1{HO zYVtPH$fA4}5QvV$lyxoDVhEK5Kbp(WxW7o9mb~Gvv|}OEtXHuh#OaD468 z2lugC8hc4SvC`OaX#8sU^R~5j{mCln(td87c;f?lx=)N}8`R?%tYvS(LrI^r#_Frz zt$9dBu8cF7ldpyXXA*1I_hg!TP6~g}hJ=%9wAI~wsH=_UP2!^yu7mWhxih_zv@*OQ z;0r~g3%B`ea8-l@z*CJIaUbGwUAQqM&=&xCG_-KoeRT|H*y*??_bN}kR8}JLrA&Um zY;VDwA+QLxoRlxHWs^tCzn_q5?APrEF3(c3w@9_Mj$Gsnc|IFOTSIp0Q zglq5kg$fm&!%0LE)cZ5!a5;W~y@#I`?a0Vdey!xBgG$1wpWc5C9lJ-WGV2+ z+(F}gUr?pqKd_B_eBDp zIdsJIY%q+K!kO}TeWmG}wGQ0_`x?3LKTuuPPiplFm9T=%Dv8%0g;$j)qF1=CV_QoE z<>nl)5Wd9fn+U#I#qf_p9$`qaiauAV!;~Zz*m)X^dzFc<1UZikcG7s@KyAkb$%?p= zq`-GG$Cct5=4?Is98)fk5SIa`4xw5IRQ%ay#&}r|A@`ZzuT-9aW*jXp%%iEDDo0^{ch7$PSQq}eykSC7PHOc zW|tW$KxkT0s2u|F(gLw2n)-o${NttAx#O-bHW6v8zYv9TlikE$4f$h`f9!B(v#G?g)sv~Ji9^W5 zWqtQz*|d3olP~dcGvzu=%kfsDueAp$P3)}hhL?J0)4D9&%^Wb~dGY#kv)wWy-H4E9 z_CvJ}yKHHbh3>ZxvTd<7y_>8~0A%?*z`$frs3hP-fFTs>dc(inDj-3qY;$fvl0iTmag!c(cod8OLeK*iFT?`w4_0m?3L(D&r z7Fq=iUZODO6Cr9&0QZyRJy3Gu9&d2myhNDOVeWAYdGd%?Q>+1}dmyVJE9ak!Q1Al- z{a{TD0xSMRQ=t>wyIsGzp8h{EN`d@=4!09df}GY6qPYam{D)`4^%Vw2gx`M036S%q z7=&m(M1MPpMMs0IAdi|o0djs|1bcQ$bu9i3zk{pUPy>i2Oy1>WK8XZ-TA#Cic;Yed za>8Bw20KrjlP#c|3qF+li!TdJH?AareDO~R8T7$8X(_gG01q49g9r5$&d5Osg$L+} zG=K&tC8)EG?hZj1AJne1e)X__6@u3~fV$7TWYC&K?2C+xM}J|FQKV~&;bnkr(}6e? z&vWwwo0&*l?4aeTXn9svulYAlpeY^#i1%F7)sUD;tS+L=Dq*8fN6X4$`{;1xs9+NU zX*%Tgewu^MVY^)Dl!v{Wzu)n5NU;P_i_-Q?0gokMl!(m@e1U7MVFI5>1o9L-CP+-t z25$MkIIQ1N%nF3g@CN3SVd@NhzKm=I0Wj-r!jb0}@7GFYTS$>~aGM%LG{obgArrQ3 zt=3R1=M%6vAbIy$_mRyFg~*+`WO+72sGn~A>ujirF|(6rD~}4CNS~-}LirHtNlbt) zm_DS+uMZ>Q~LH*SMNkJ>T!4i_|%ZBp8V=XLtY#$3suZLQM0|0rGrLM(v zNjrK#!rw4cV@4<2+LiE~sPI3_#!# zz_vt+&uyqOBR|dbuhz=55(<0Nk<~@o%Mz(EaR`jfKYw4jvNIq{9UqC20?JE2F^{i4J~rN^X;*& zSIWFCIRY4L2hz1K;_Fpm@4@yf+2LYEa@#7Rh){qxlUOA)8nSDRieQh9HdaR@P(0Lm zcIKe6UX32RgPHeC8PH7iJw`R4-*YgKz*CvV%=W`QmztR2qaN?|raqd@Uk2l@o5u;5 zC)mUe1F^xCngZn<^KAT8&`5^?i;Lv6rVl!LK%*w7Dt>Ue9q3`zUf|g%J`LFtW)C#rPfGRfu_uC)M%kk*M|{CmoQnf~EcUfTE$j@!l|Kud0Cp}J-Oy76 zMrH^om3#%x3|(ZN4aB>DV_YnG=SJDDiX~C?S%6?_x3`{gW~r~jbR-f1adjujD8s^= z0pwemtTsZVT(sQoJvvJd0MB<55A)x~TmyzI0QI4%?=`?1xP>)^`TbGLH)CeK(a?3H zTP*}A!Ka1kFjH#>>Hf>4ZIOrp-N193eD2lqIxz9Yw9jyCdq>A^7s=l+jo)gdltPp|#{ZEU! zF?>=Zs!oD)eKL?9Swwt68z}@FL?*e`5?7W1;3mav{u*hsIt)TuS`J;Z?&n3 zfZjc7KCbpC-A(Bt@gTBB&@NYQVN@3F?8p&2v|s1<^OLCR-H)R&U#<6yr_2Z0Y29bB@;IYwXD~ zEa60Nf-|Tjs{n8=A{~@Sfr|8rKOZEd7?e&j<8YAG za&RxIC+P(>Kt=)zCW|fO=l46-O1pS=Uoqqs1v{RR?q-!Ujz&zk-C|VV?nwvQ6L*O_ zzamUFQnLgWM?Tg_aYXJ;?JAGKmw&07?)GGvZa-E|4=Wf~XD+v97{=$}Hig_63Lmt$ zS@ah&Bg@!_UL6Wq)O@{?V~(=yenm7k2ocB)SijG4mhAtuk#Apjs*Nz+pPXGe1c^b- z4Cc9`A#w*kvu-q3$$NIuJvL6m+e~GIGIgKTqE)o|TIjG(o1-#(uJjqt2y2t@(B$Ru z4$pi%YNoSCmdEe`p$cUc@Xaa0Vsuwuo#NN5CV)>29T!1UM?X~{(+MO0$VFflr;fbQ zI3NQ!82tD)G7>0GQK(5##zesgSsu9ff_KT0xbvHyk)Y2nmt?zN{D$NE3o+jSa{_JP zzuxj-6p#Y=6N$xZgmYX9XMMI;ni`whYCmcA(CQnDEWnLrf%%DW5bSU@B{)hB@C_xU8V(!W4b$>TRlVA5xnlxo0u2xn{gXSAkh6LTvPjx^8aKlkb%`Sj zCi#tPREiBg8IpnM`XkhbiE!@lH~|G z0>?<}Af0|VVzRzr=t;o_Xhg~WEEz&3)LxK&e8^sZjh5WQcjGP0fj_G2(p02C5!-;4 z!Q{kQ$<)*ko}B2nQBoNRvDk?aoKp`W=CcxXXqriYvUHW419O0YF>)djKJs*1FTj`n zT4WDh2pM#{h3lHKn|(bJ80JyGql3awQd@(A!C#P0%Iqz;wql@BNrbtmKZe?qefQv?lp zK4b>*8%qc59a)Tu5G-lsprxUGyh#zDd1I6hz}>7|46aOqpHyd>9LktynjD7*mLzLT za~d)vdi7-@&GKPxlZxcsyID5!%g_DMPb?&fO{&d1aIt-x$BpqsVN{M--=oBn96~L2 z>D6V1AY&%U_8UXWxpl6aetf1TZpdZXqcN*v6zvD zA8ARnNvk9GlTvaJeCUc7x+tR?_!{lJOky9zXk@n|o`q)m097Kb;id&+nT1LXJF)~3 z?ToD;GbII(I^2oqyy!3&>i1LqMvkv8I)8@z0Mf+atYg2J)y?jz6-A>7iHh6!G8?~j zbf%U+H`)dsV?PRlQS4E0A<8vz(f$(9o=FLoPw+?y&!H`oXw={SImK?4Q$CyF(;9f* zLX7*n007Vj%X^ms56Z+>zvjDjeVdUU+tH-!=8CJ6bZD6&Fn0x-7;B`IHm}9`eMoW! zFzVcQ1_d(>W)8$e7mtRsw2%&> zE6gRbIBtnJo)JYYlY|)$H?zqT-fX(_NRrnFb2ORPK-Qks{(?TcF8`-T^$U&R+2gte zWV57Fyy!}4l^=TmIE>7TBB~2PFl@fqGH5;s^eA4N4Hb`iQB5~D8{D+v;OK2+?cBI{ z)oW~TbbyMWjIT>Dm5=r8m4$X_7YH*I8sZOQ{&g!=#+aK4NWYTV9abjg|H_9w*MM?icchZHz z+j;Qx?>5Teu^b8X{LVYqo!q}rId*_F8o*zlkk&YQH^V+lA-%wi%`l4(@1MQME@29s zfg~gy09u})x(cb^F)tN@*o$ttGY5$--=H2iv(qcr{3X_ft=Y1^Vn`^Jcwij+Y`)q^ zhR-|81l_TW8Y9=psM+3g3`~gN_Av?PVvqM5hXqV?&@Y=;jfWnjOKV92E}F+3Ls6XGJ9zXJ3gg1hj$U9V zEiQjd;b%f${IzT64Wb#Bs5cxkukux_Hc~5TmE+gSEc3(*zxSFuo@?p} zr1!4-iVr@%RR7>kY+jl9pk^>-i=|@on1lx6Hf7((GD~^f87JSXy^yz)>%+FVd^ty8 z29}^(F#irQYB9!!!dQsU*4+xPLEaz~53<%xt%7JFG23jrk!x;gjo3+)sU~%xitw###`8o@L|4A`_EO<=7gR*O;+G-k)o?s{wkt z&ysO{Rohm3UQ5~Wn>_6zGccOT--^C!b`rjUglQ`anY=-l$(KlXWm4F?c2>Uz=goMa zjsSzJ4i>Fqaz^-p@4A28N`#`DJx`MzHhaed8OQ1`1&=%XD z9JB1=(weU1CPd0o`&40YurA4gH0}!T|8z7PvCahg&3bqX5n` z){#M9Dh9vs%0LhqJvLm1afsNXFstf%62|!Ho~d^DFI;Dso_S;YZEn1IBjD@Uro*8i zrBN32j+||4%+C+-bUmFS9C#W?a-&b#D#%m_g-aoAAiE`Mz}Pi|BBFc{g1zz&bD}_P)iN>#9JHbJ;5N zdxJwDw!sM3Fs!FhnZ!!}JRT$Y^)(%C)CJ40kryx;j$YKJ?}_q5qdi_qG1j74$2vuG z#NeDPOt3Sl&a#+FwWL{mJ3fmk&r4(?>aTet2U{K-dX%iOYk9G*$I0Go!fZo@s+kn^ zhgbM$zeVS}Qu*>rPZ>VM8{+0GOoX&5Trwe`enf|T@oL1`!zkNDE=^H;&u^^RKM{iM zR6=KUH=o4ippyrdt%#Nwa@dE9Y;IeanFtfHhc~^{KLRKNL@v3jJLvRgeI6W1$nhTpNz#L*^lVXhT z-J>4f2tRv~HUcG5e)%`Jp08eOEnw;VK5Lj)Vy9d!x7?3$1eP6NVDX0%ZR;Bq=gixK z&Tk*cyZ@fE#vP%kTidOdiuAXVouR~cenycdWFL(qRg7o3*|givd?rX(&*o5mk~1>7 z1Y)*<-uMC^%KR2!Iy^l8SaK33E9sE+?Com_z$_BTqSLD=xKz2#x2~8a%9a5mVTnsY zSFFW$8F7>kHYd6ZI98x6tvFQ}a6I~`d>d0PraDqkJt1OTC(7^QqV~x$E@atungE$H?4hAWZMNZi zIDtQa*T|JhP#5~`?AoJf_oRo&jO}jypuUJ0V|avNlAF|uiBguunb23RJ=Z$*XJm}i z!YniPt9mVM8XtIN>%D?0qD%a~*R$4XxRTcAvEMgn`N6Czpl60Buw?&*Wc=Q;#t_ZY z7rD64Uw}&a(2ot}{AnU_J zc-2ie*;WV(sl8zw81m)3xQZRMb~Pa~P*J6k_lcxbHRVmu(l9rxmIFk~WDx0#V0p*U z3TBYPEIUj}&KmK)n>)8@WxaI0Nm$QIDLnS9$8&q^qJxbH$@HX`_(T899gNXDf|?x)8s*lFYFw5CCIUxR(gH! zNMm=98f=SRF>gEjSbJn75?(VI&i}Fy9e_<4Lu}R-eL)^AfYhp4n_1>z*F%%y9F1MN zaAsyA!szbV#c5;Gyh>nani#@?12_j~2vo5(n=ck96sFi#Jgt6+Rd=|zV#EDap~L zvY|S|+7uOi^bvv_46r+R@P5Naq3_g4agToL{D_17FN)cs;NV3Z3^%EF^TxWNw zZtlzVZt1wBd(Haz3a>@`LYA2q=71~2!lh|y$wg3)rJg@x9$kGLx9RFnkqX~TcetFF zU(eqHc%4Eu<>g%{Jg@z6dPE%hY2bFg2>W`bl7X`eP<~5@5UKJZBi1L}lhS+`7pkQM z^z)ZW4n6t(2oDb+Km z3R%`y%QpLsY?2|4rEUnh53KjH{&|$+P6ES_MzYCfWrmbK3WmHI1Z$j(9HPdFB^SD8 ztYw^xKstMdTf`lSi#%5zZgwB;l5B~IE?M)}Oppm$Ws9DVigBJ^ngZw{{1Xu`%4n~~ z4F|o5F_iReYRQz}XY3u!GfdpsU_xR6+M{=`WL);}DkZ+{b!7{7ri8W<0d}Uh;uZ4@ zCXYk~k^2<)Dz1lKDaCKAS9(Ei^@7YWHA(cyM8)SNJ-@ilK-_zXmp#o%m<0-GtB~lQ zzi-!??_^Rv8o9_JKc3N6-`;TLNAyTidc37@`SkPI&CNIDzP!xCdU|BJt-VX)XLBFV z(9yrWII-2CY^+_cikpt()cOLUj*19Z`jOc)PDZ-=T;UnRxkh>!G!2 zI+&AdrjNa7R}n@`OfcZv(bVs2O&-zheX!wstb-lNJIA0R?AxkLeq`j~uXqC^PclHz zpxh0Z{oIgwwe2pQK-OJdBK9@`OA!&LknUa2QX*tfnc_PRxvn#`nDr-i$}iC$ zBMR=>nEYg~=nbmjp%X)^vh7{m#g8-?ks77a)2K!ec^#w%e3$K+B2RXSATp-B9$bwUQyQ>Woi>g%c;~l zf#v{-l55vPk4?N zD?FVnAr%oB=pC6?Trj$V#c51=QREybYy-$8ld4on+x5LLPdq0j>-|>^M9ghxl+VTA z+rBet{fyW&YRWoJQ-m4A5f0hIHGQ*RG>K3G#Ef0iSI408QE^jR|0U+%Bg*{jR^( zgTlfLbY-G5i+3MuF`e_b^_%Le*^ah+wS3-LmLh=b6<^un%i7&2kEHO%^7Vs`l|XXp zr{jB1Jlag(BK&lcT^8Hcj#W+A1T0^(q-1uooi!#a4f}-F9RTXlDWj9`U*pVD^!br+ za>mk2Nm&&e-s>a#IpOQ2Lsxh&W)<#_dQdc^Mg+$%_az+hcehB!HA%Isr}3aDx~C^3 zucI4%BB#&;ATuf8TnONGCBjm8sX6IhVmcC=dQWIT533g^===l1N z|8^po=^I6X$b_PG^Lvk79x-9sg~EAE5nJpa_R#0}*?;o9g(w9bY=n zl5on^?F2QEvD5Qpwyu21ZFu}U?U~r6w8*n^t=qS?zA2>n`0m|lk$oJ>?Hr5@TxIP$kXqTq%(R(pJ(s0${bJTBpoph~{++O<4Uw0YsXA&Hq3 zPCLD%(mqxKmVctFL49D;6N+|W$(W2rlz zm7OH&ZQp?vu4vQ_0T15Impz#G?F%9c4-U~XTK$xB#`1{rm3viu(K42oLw^9+QHr=QLR}dfeW(=bP-Tlvd3M8hnsI z>F*FwM>QSX1A>b->)Q6Y&Rlm)W!qe~p-nAQbuFPuqdiR-B4{B*@k^P$EkGr4s%yn%Wflr?vN2h95T|Ymu-mUrFMF98S zD(u~10tL)eVguZa--A<{m!5%Jt1M?f@fSS z(oa91Y{!w+HMiz)_r~@E!S%0=%KU-olGRGdV^F!akD7rWTV``x2C~49#mm53z1{yd z#$_$9R{7T*_ci#T$LvycjeOx!cJ=O}=EYtjm1y7}&jshg`BK%ZsdE<^6+^p4ee9k10hW_h002aGf$rwVDdLx`|wJH8i$mSj7fVXNXan$CKtXJ&*5x;ZY zqz9nyQjbnQ&^Zzkz;dInFAl(5AxZ=!&j3b6Gl28GqD8 zj&Uzp@qbFH%*%qOKMObRl`uS^YQ`qINOTVynd+o?e3!?_vY8acO)RYtlaePV?neWl z&D_NF#~b_=ku+RDKX9G58iE`t{#wQLl`947|AvDFB%~{II)K6W*TVpi)CT7<+;3m> zKY{y+$DNlAhE8g%uL6~LXw%92pMqFHIkI*qe`JT4h=weX--SU)pzh?RR39j@1#86@ z1v3Bkvw#MqJQ$Eiq=F~noN@)CNBr;KjF_k1!>37g@4iwlwe5zt3Ey&g<&so_dkeqp z4?Ds4-KaskY}vO3oK^j8tu&5XhWxWVpY?K!HF;%SA_snm2~^y9**)-*ZXD^|aZDHg z^YHI$fS@h?DqKG?YDR!S-d+8FVKX$8ER*lOgM_&5i32B(%XL#4yqk6dR(gnk#%Kx* zj7QXb!bt*(8^p3$66OEf%ma!#If%OaM#l*P3OE}0xtfOwv?r(~^C5sU0`rR+I8csh6|NneR4{JuGxWNzODzJU8DP~V% z74m1BQ^KnTA`zAM1G81CM1~O)KuN1vUjXo^h->ZXNXB%j1+z{xDes zXiqk3}b!^RHG_pk z5^+sM@%HH){ Date: Fri, 27 Jun 2025 17:39:49 +0500 Subject: [PATCH 36/37] fix: publish btn doesn't show after component edit When we edit & save the component, publish button doesn't show up until we refresh the page manualy or open this unit by opening previous unit and coming back to this unit again. In this commit, we are dispatching a storage event whenever we edit the component, it'll refresh the page & show the publish button as expected. --- src/course-unit/hooks.jsx | 18 ++++++++++++++++++ src/editors/data/redux/thunkActions/app.js | 10 ++++++++++ .../data/redux/thunkActions/app.test.js | 6 +++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 7ab5eb5918..f707bae452 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -217,6 +217,24 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }, [isMoveModalOpen]); + useEffect(() => { + const handlePageRefreshUsingStorage = (event) => { + // ignoring tests for if block, because it triggers when someone + // edits the component using editor which has a separate store + /* istanbul ignore next */ + if (event.key === 'courseRefreshTriggerOnComponentEditSave') { + dispatch(fetchCourseSectionVerticalData(blockId, sequenceId)); + dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType)); + localStorage.removeItem(event.key); + } + }; + + window.addEventListener('storage', handlePageRefreshUsingStorage); + return () => { + window.removeEventListener('storage', handlePageRefreshUsingStorage); + }; + }, [blockId, sequenceId, isSplitTestType]); + return { sequenceId, courseUnit, diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index 8da9f24b71..4c5079a5a9 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -125,6 +125,16 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => { content, onSuccess: (response) => { dispatch(actions.app.setSaveResponse(response)); + const parsedData = JSON.parse(response.config.data); + if (parsedData?.has_changes) { + const storageKey = 'courseRefreshTriggerOnComponentEditSave'; + localStorage.setItem(storageKey, Date.now()); + + window.dispatchEvent(new StorageEvent('storage', { + key: storageKey, + newValue: Date.now().toString(), + })); + } returnToUnit(response.data); }, })); diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 35debc7f3c..3f8dc10c9e 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -352,7 +352,11 @@ describe('app thunkActions', () => { }); it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => { dispatch.mockClear(); - const response = 'testRESPONSE'; + const mockParsedData = { has_changes: true }; + const response = { + config: { data: JSON.stringify(mockParsedData) }, + data: {}, + }; calls[1][0].saveBlock.onSuccess(response); expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response)); expect(returnToUnit).toHaveBeenCalled(); From 088675f60ed329351c78b20b7c9f394eea65ceed Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Sat, 29 Nov 2025 17:56:08 +0200 Subject: [PATCH 37/37] feat: add logic for ai creation --- src/CourseAuthoringRoutes.jsx | 193 +++++++++--------- .../AIAssistantChat/AIAssistantChat.scss | 96 +++++++++ src/assistant/AIAssistantChat/hooks.ts | 72 +++++++ src/assistant/AIAssistantChat/index.tsx | 117 +++++++++++ .../AIAssistantChat/tests/hooks.test.tsx | 89 ++++++++ .../AIAssistantChat/tests/index.test.tsx | 106 ++++++++++ src/assistant/AIAssistantWidget/index.tsx | 50 +++++ src/assistant/context/AIAssistantContext.ts | 5 + src/assistant/context/AIAssistantProvider.tsx | 29 +++ src/assistant/context/__mocks__/hooks.ts | 10 + src/assistant/context/hooks.ts | 11 + .../tests/AIAssistantProvider.test.tsx | 72 +++++++ src/assistant/data/api.ts | 17 ++ src/assistant/data/apiHooks.ts | 10 + src/assistant/types.ts | 37 ++++ .../CourseTeamMember.test.jsx | 2 + src/editors/EditorPage.test.tsx | 2 + .../containers/EditorContainer/index.test.tsx | 7 + .../containers/EditorContainer/index.tsx | 18 +- .../__snapshots__/index.test.jsx.snap | 6 +- src/editors/containers/TextEditor/index.jsx | 37 +++- .../containers/TextEditor/index.test.jsx | 2 + .../sharedComponents/CodeEditor/hooks.js | 16 +- src/index.scss | 1 + .../add-content/AddContent.test.tsx | 2 + .../add-content/AddContentWorkflow.test.tsx | 2 + .../units/LibraryUnitPage.test.tsx | 2 + 27 files changed, 902 insertions(+), 109 deletions(-) create mode 100644 src/assistant/AIAssistantChat/AIAssistantChat.scss create mode 100644 src/assistant/AIAssistantChat/hooks.ts create mode 100644 src/assistant/AIAssistantChat/index.tsx create mode 100644 src/assistant/AIAssistantChat/tests/hooks.test.tsx create mode 100644 src/assistant/AIAssistantChat/tests/index.test.tsx create mode 100644 src/assistant/AIAssistantWidget/index.tsx create mode 100644 src/assistant/context/AIAssistantContext.ts create mode 100644 src/assistant/context/AIAssistantProvider.tsx create mode 100644 src/assistant/context/__mocks__/hooks.ts create mode 100644 src/assistant/context/hooks.ts create mode 100644 src/assistant/context/tests/AIAssistantProvider.test.tsx create mode 100644 src/assistant/data/api.ts create mode 100644 src/assistant/data/apiHooks.ts create mode 100644 src/assistant/types.ts diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 39808ab8a8..93968edfc9 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -27,6 +27,7 @@ import CourseChecklist from './course-checklist'; import GroupConfigurations from './group-configurations'; import { CourseLibraries } from './course-libraries'; import { IframeProvider } from './generic/hooks/context/iFrameContext'; +import { AiAssistantProvider } from './assistant/context/AIAssistantProvider'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -48,101 +49,103 @@ const CourseAuthoringRoutes = () => { const { courseId } = useParams(); return ( - - - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - } - /> - } - /> - {DECODED_ROUTES.COURSE_UNIT.map((path) => ( - } - /> - ))} - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - : null} - /> - } - /> - - + + + + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + } + /> + } + /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + : null} + /> + } + /> + + + ); }; diff --git a/src/assistant/AIAssistantChat/AIAssistantChat.scss b/src/assistant/AIAssistantChat/AIAssistantChat.scss new file mode 100644 index 0000000000..9a424ce068 --- /dev/null +++ b/src/assistant/AIAssistantChat/AIAssistantChat.scss @@ -0,0 +1,96 @@ +.ai-assistant-chat { + width: 100%; + max-width: 360px; + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: .875rem; + background-color: #FFFFFF; + box-shadow: 0 4px 12px rgb(0 0 0 / .15); + + &__message-area { + height: 350px; + overflow-y: auto; + padding: 12px; + background-color: #F9F9F9; + display: flex; + flex-direction: column; + gap: 12px; + scroll-behavior: smooth; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: rgb(0 0 0 / .2); + border-radius: 3px; + } + } + + &__message { + padding: 8px 12px; + border-radius: 12px; + max-width: 85%; + word-break: break-word; + line-height: 1.4; + color: white; + font-size: 14px; + + &--user { + align-self: flex-end; + background-color: #6C757D; + border-bottom-right-radius: 2px; + } + + &--ai { + align-self: flex-start; + background-color: #007BFF; + border-bottom-left-radius: 2px; + } + } + + &__form { + display: flex; + padding: 12px; + border-top: 1px solid #E0E0E0; + gap: 8px; + align-items: flex-end; + background-color: #FFFFFF; + } + + &__input-wrapper { + flex-grow: 1; + position: relative; + } + + &__textarea { + width: 100%; + resize: none; + min-height: 40px; + max-height: 150px; + padding: 2px !important; + border-radius: 8px !important; + + &:focus { + box-shadow: 0 0 0 .2rem rgb(0 123 255 / .25); + } + + & textarea { + font-size: 16px !important; + max-height: 150px; + } + } + + &__send-btn { + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 !important; + border-radius: 8px !important; + flex-shrink: 0; + } +} diff --git a/src/assistant/AIAssistantChat/hooks.ts b/src/assistant/AIAssistantChat/hooks.ts new file mode 100644 index 0000000000..1780f61363 --- /dev/null +++ b/src/assistant/AIAssistantChat/hooks.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; + +import { useAiAssistant } from '../context/hooks'; +import { UseAIAssistantChatPropsI, AssistantMessage, GenerateAiContentReqI } from '../types'; + +const useAIAssistantChat = ({ xblockType }: UseAIAssistantChatPropsI) => { + const [messages, setMessages] = useState([]); + + const { + generateContent, isAiLoading, aiData, courseId, + } = useAiAssistant(); + + useEffect(() => { + const lastMessage = messages[messages.length - 1]; + + if (lastMessage?.type === 'user' && !isAiLoading) { + const newMessageId = `ai-${Date.now()}`; + + if (aiData) { + setMessages((prev) => [ + ...prev, + { + id: newMessageId, + type: 'ai', + text: 'The content has been successfully generated and inserted into the editor.', + variant: 'success', + }, + ]); + } else { + setMessages((prev) => [ + ...prev, + { + id: newMessageId, + type: 'ai', + text: 'Failed to generate content. Please try again.', + variant: 'danger', + }, + ]); + } + } + }, [isAiLoading, aiData]); + + const handleSend = (prompt: string) => { + const userPrompt = prompt.trim(); + if (!userPrompt || isAiLoading || !courseId) { return; } + + setMessages((prev) => [ + ...prev, + { + id: `user-${Date.now()}`, + type: 'user', + text: userPrompt, + }, + ]); + + const payload: GenerateAiContentReqI = { + course_id: courseId, + xblock_type: xblockType, + prompt: userPrompt, + }; + generateContent(payload); + }; + + return { + messages, + handleSend, + isAiLoading, + isReady: !!courseId, + }; +}; + +export default useAIAssistantChat; diff --git a/src/assistant/AIAssistantChat/index.tsx b/src/assistant/AIAssistantChat/index.tsx new file mode 100644 index 0000000000..edb30e741e --- /dev/null +++ b/src/assistant/AIAssistantChat/index.tsx @@ -0,0 +1,117 @@ +import { Button, Form, Spinner } from '@openedx/paragon'; +import { Send } from '@openedx/paragon/icons'; + +import { Formik } from 'formik'; +import { Fragment, useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +import { AssistantEditorFormPropI } from '../types'; + +const AIAssistantChat = ({ + messages, + onSend, + isLoading, + isReady, + placeholder, +}: AssistantEditorFormPropI) => { + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (messages && messages.length > 0) { + messagesEndRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } + }, [messages]); + + const adjustTextareaHeight = (el: HTMLTextAreaElement) => { + if (el) { + // eslint-disable-next-line no-param-reassign + el.style.height = 'auto'; + // eslint-disable-next-line no-param-reassign + el.style.height = `${Math.min(el.scrollHeight, 150)}px`; + } + }; + + return ( +

+
+ {messages && messages.map((msg) => ( + +
+ {msg.text} +
+
+ ))} +
+
+ { + if (!values.prompt.trim()) { + return; + } + onSend(values.prompt); + resetForm(); + + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }} + > + {({ values, handleChange, handleSubmit }) => ( +
+
+ { + handleChange(e); + adjustTextareaHeight(e.target as HTMLTextAreaElement); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ +
+ )} +
+
+ ); +}; + +export default AIAssistantChat; diff --git a/src/assistant/AIAssistantChat/tests/hooks.test.tsx b/src/assistant/AIAssistantChat/tests/hooks.test.tsx new file mode 100644 index 0000000000..636a601d77 --- /dev/null +++ b/src/assistant/AIAssistantChat/tests/hooks.test.tsx @@ -0,0 +1,89 @@ +import useAIAssistantChat from '../hooks'; +import { act, renderHook } from '../../../testUtils'; +import { useAiAssistant } from '../../context/hooks'; + +jest.mock('../../context/hooks', () => ({ + __esModule: true, + useAiAssistant: jest.fn(), +})); + +const mockUseAiAssistant = useAiAssistant as jest.Mock; + +describe('useAIAssistantChat', () => { + const mockGenerateContent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: undefined, + courseId: 'course-v1:test', + }); + }); + + test('should initialize with empty messages', () => { + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + expect(result.current.messages).toEqual([]); + expect(result.current.isReady).toBe(true); + }); + + test('should add user message and call generateContent on handleSend', () => { + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Test prompt'); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].text).toBe('Test prompt'); + expect(result.current.messages[0].type).toBe('user'); + + expect(mockGenerateContent).toHaveBeenCalledWith({ + course_id: 'course-v1:test', + xblock_type: 'html', + prompt: 'Test prompt', + }); + }); + + test('should not send message if courseId is missing', () => { + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: undefined, + courseId: undefined, + }); + + const { result } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Test prompt'); + }); + + expect(result.current.messages).toHaveLength(0); + expect(mockGenerateContent).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(false); + }); + + test('should add AI success message when loading finishes with data', () => { + const { result, rerender } = renderHook(() => useAIAssistantChat({ xblockType: 'html' })); + + act(() => { + result.current.handleSend('Hello'); + }); + + mockUseAiAssistant.mockReturnValue({ + generateContent: mockGenerateContent, + isAiLoading: false, + aiData: { content: 'Generated HTML' }, + courseId: 'course-v1:test', + }); + + rerender(); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[1].type).toBe('ai'); + expect(result.current.messages[1].variant).toBe('success'); + }); +}); diff --git a/src/assistant/AIAssistantChat/tests/index.test.tsx b/src/assistant/AIAssistantChat/tests/index.test.tsx new file mode 100644 index 0000000000..7b9690b7a6 --- /dev/null +++ b/src/assistant/AIAssistantChat/tests/index.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import AIAssistantChat from '..'; +import { + render, screen, waitFor, initializeMocks, +} from '../../../testUtils'; + +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +jest.spyOn(React, 'useEffect').mockImplementation(React.useLayoutEffect); + +describe('AIAssistantChat', () => { + const mockOnSend = jest.fn(); + + const defaultProps = { + messages: [ + { id: '1', type: 'user' as const, text: 'Hello, explain React components.' }, + { id: '2', type: 'ai' as const, text: 'React components are reusable building blocks.' }, + ], + onSend: mockOnSend, + isLoading: false, + isReady: true, + placeholder: 'Type your question...', + }; + + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('should render the chat component and display messages correctly', () => { + render(); + + expect(screen.getByPlaceholderText(/Type your question/i)).toBeInTheDocument(); + expect(screen.getByText(/Hello, explain React components/i)).toBeInTheDocument(); + expect(screen.getByText(/React components are reusable building blocks/i)).toBeInTheDocument(); + }); + + test('should call onSend with prompt and reset the form on submission', async () => { + render(); + + const input = screen.getByPlaceholderText(/Type your question/i); + + const sendButton = screen.getByRole('button'); + + const testPrompt = 'Write a test case for Formik.'; + + userEvent.type(input, testPrompt); + expect(input).toHaveValue(testPrompt); + + userEvent.click(sendButton); + + await waitFor(() => { + expect(mockOnSend).toHaveBeenCalledTimes(1); + }); + + expect(mockOnSend).toHaveBeenCalledWith(testPrompt); + + await waitFor(() => { + expect(input).toHaveValue(''); + }); + }); + + test('should disable the send button when isReady is false', () => { + render(); + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + }); + + test('should display Spinner and disable the button when isLoading is true', () => { + render(); + + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + test('should disable the send button when the input is empty or only whitespace', async () => { + render(); + + const input = screen.getByPlaceholderText(/Type your question/i); + const sendButton = screen.getByRole('button'); + + expect(sendButton).toBeDisabled(); + + userEvent.type(input, 'test'); + expect(sendButton).not.toBeDisabled(); + + userEvent.clear(input); + expect(sendButton).toBeDisabled(); + + userEvent.type(input, ' '); + expect(sendButton).toBeDisabled(); + }); + + test('should use the provided placeholder text', () => { + const customPlaceholder = 'Ask the AI to do something cool.'; + render(); + + expect(screen.getByPlaceholderText(customPlaceholder)).toBeInTheDocument(); + }); +}); diff --git a/src/assistant/AIAssistantWidget/index.tsx b/src/assistant/AIAssistantWidget/index.tsx new file mode 100644 index 0000000000..eda6d83f6e --- /dev/null +++ b/src/assistant/AIAssistantWidget/index.tsx @@ -0,0 +1,50 @@ +import { Popover, OverlayTrigger, Button } from '@openedx/paragon'; +import { Chat } from '@openedx/paragon/icons'; + +import AIAssistantChat from '../AIAssistantChat'; +import useAIAssistantChat from '../AIAssistantChat/hooks'; +import { UseAIAssistantChatPropsI } from '../types'; + +const AIAssistantWidget = ({ xblockType }: UseAIAssistantChatPropsI) => { + const { + messages, + handleSend, + isAiLoading, + isReady, + } = useAIAssistantChat({ xblockType }); + + const chatPopover = ( + +
+ +
+
+ ); + + return ( + + + + ); +}; + +export default AIAssistantWidget; diff --git a/src/assistant/context/AIAssistantContext.ts b/src/assistant/context/AIAssistantContext.ts new file mode 100644 index 0000000000..d32998fffc --- /dev/null +++ b/src/assistant/context/AIAssistantContext.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +import { AiAssistantContextType } from '../types'; + +export const AiAssistantContext = createContext(null); diff --git a/src/assistant/context/AIAssistantProvider.tsx b/src/assistant/context/AIAssistantProvider.tsx new file mode 100644 index 0000000000..213c496b06 --- /dev/null +++ b/src/assistant/context/AIAssistantProvider.tsx @@ -0,0 +1,29 @@ +import { useParams } from 'react-router-dom'; +import { useMemo } from 'react'; + +import { AiAssistantContext } from './AIAssistantContext'; +import { useGenerateAiContent } from '../data/apiHooks'; + +export const AiAssistantProvider = ({ children }: { children: React.ReactNode }) => { + const { + mutate: generateContent, + isLoading: isAiLoading, + data: aiData, + } = useGenerateAiContent(); + + const params = useParams(); + const courseId = params?.courseId; + + const value = useMemo(() => ({ + generateContent, + isAiLoading, + aiData, + courseId, + }), [generateContent, isAiLoading, aiData, courseId]); + + return ( + + {children} + + ); +}; diff --git a/src/assistant/context/__mocks__/hooks.ts b/src/assistant/context/__mocks__/hooks.ts new file mode 100644 index 0000000000..f594d6d193 --- /dev/null +++ b/src/assistant/context/__mocks__/hooks.ts @@ -0,0 +1,10 @@ +import { AiAssistantContextType } from '../../types'; + +const defaultMockContext: AiAssistantContextType = { + generateContent: jest.fn(), + isAiLoading: false, + aiData: undefined, + courseId: 'mocked-course-v1:Global+MFE+001', +}; + +export const useAiAssistant = jest.fn(() => defaultMockContext); diff --git a/src/assistant/context/hooks.ts b/src/assistant/context/hooks.ts new file mode 100644 index 0000000000..3db5e8de92 --- /dev/null +++ b/src/assistant/context/hooks.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; + +import { AiAssistantContext } from './AIAssistantContext'; + +export const useAiAssistant = () => { + const context = useContext(AiAssistantContext); + if (context === null) { + throw new Error('useAiAssistant() must be used within an AiAssistantProvider'); + } + return context; +}; diff --git a/src/assistant/context/tests/AIAssistantProvider.test.tsx b/src/assistant/context/tests/AIAssistantProvider.test.tsx new file mode 100644 index 0000000000..170fb2cbbd --- /dev/null +++ b/src/assistant/context/tests/AIAssistantProvider.test.tsx @@ -0,0 +1,72 @@ +import { useParams } from 'react-router-dom'; + +import { useAiAssistant } from '../hooks'; +import { AiAssistantProvider } from '../AIAssistantProvider'; +import { render, screen, initializeMocks } from '../../../testUtils'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + __esModule: true, + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('../../data/apiHooks', () => ({ + useGenerateAiContent: () => ({ + mutate: jest.fn(), + isLoading: false, + data: undefined, + }), +})); + +const TestConsumer = () => { + const { courseId } = useAiAssistant(); + return
Course ID: {courseId || 'No Course ID'}
; +}; + +const mockUseParams = useParams as jest.Mock; + +describe('AiAssistantProvider', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('renders children without crashing', () => { + mockUseParams.mockReturnValue({ courseId: 'course-v1:test' }); + + render( + +
Test Child
+
, + ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + test('provides courseId from URL params to consumers', () => { + mockUseParams.mockReturnValue({ courseId: 'course-v1:edX+DemoX+Demo_Course' }); + + render( + + + , + ); + + expect(screen.getByText('Course ID: course-v1:edX+DemoX+Demo_Course')).toBeInTheDocument(); + }); + + test('handles missing params gracefully (no crash in unit tests)', () => { + mockUseParams.mockReturnValue({}); + + render( + + + , + ); + + expect(screen.getByText('Course ID: No Course ID')).toBeInTheDocument(); + }); +}); diff --git a/src/assistant/data/api.ts b/src/assistant/data/api.ts new file mode 100644 index 0000000000..0c06f4f167 --- /dev/null +++ b/src/assistant/data/api.ts @@ -0,0 +1,17 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig, camelCaseObject } from '@edx/frontend-platform'; + +import { GenerateAiContentReqI, GenerateAiContentResI } from '../types'; + +const getApiBaseUrl = () => getConfig()?.LMS_BASE_URL || ''; + +export const generateAiContent = async ( + payload: GenerateAiContentReqI, +): Promise => { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post( + `${getApiBaseUrl()}/oex_ai_content_assistant/api/ai-content/generate/`, + payload, + ); + return camelCaseObject(data); +}; diff --git a/src/assistant/data/apiHooks.ts b/src/assistant/data/apiHooks.ts new file mode 100644 index 0000000000..801a95b0e1 --- /dev/null +++ b/src/assistant/data/apiHooks.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; + +import { generateAiContent } from './api'; +import { GenerateAiContentReqI } from '../types'; + +export const useGenerateAiContent = () => ( + useMutation({ + mutationFn: (payload: GenerateAiContentReqI) => generateAiContent(payload), + }) +); diff --git a/src/assistant/types.ts b/src/assistant/types.ts new file mode 100644 index 0000000000..10c0df6fc9 --- /dev/null +++ b/src/assistant/types.ts @@ -0,0 +1,37 @@ +export type MessageType = 'user' | 'ai'; + +export interface AssistantMessage { + id: string; + type: MessageType; + text: string; + variant?: 'success' | 'danger'; +} + +export interface GenerateAiContentReqI { + course_id: string; + xblock_type: string; + prompt: string; +} + +export interface GenerateAiContentResI { + content: string; +} + +export interface AiAssistantContextType { + generateContent: (values: GenerateAiContentReqI) => void; + isAiLoading: boolean; + aiData: GenerateAiContentResI | undefined; + courseId: string | undefined; +} + +export interface UseAIAssistantChatPropsI { + xblockType: string; +} + +export interface AssistantEditorFormPropI { + messages: AssistantMessage[]; + onSend: (prompt: string) => void; + isLoading: boolean; + isReady: boolean; + placeholder?: string; +} diff --git a/src/course-team/course-team-member/CourseTeamMember.test.jsx b/src/course-team/course-team-member/CourseTeamMember.test.jsx index be6719ac6a..59c0c2dbc3 100644 --- a/src/course-team/course-team-member/CourseTeamMember.test.jsx +++ b/src/course-team/course-team-member/CourseTeamMember.test.jsx @@ -6,6 +6,8 @@ import { USER_ROLES } from '../../constants'; import CourseTeamMember from './CourseTeamMember'; import messages from './messages'; +jest.mock('../../assistant/context/hooks'); + const userNameMock = 'User'; const emailMock = 'user@example.com'; const currentUserEmailMock = 'user@example.com'; diff --git a/src/editors/EditorPage.test.tsx b/src/editors/EditorPage.test.tsx index 9685906832..887cab69dc 100644 --- a/src/editors/EditorPage.test.tsx +++ b/src/editors/EditorPage.test.tsx @@ -8,6 +8,8 @@ import editorCmsApi from './data/services/cms/api'; import EditorPage from './EditorPage'; +jest.mock('../assistant/context/hooks'); + // Mock this plugins component: jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: diff --git a/src/editors/containers/EditorContainer/index.test.tsx b/src/editors/containers/EditorContainer/index.test.tsx index f56a096655..c9a899e25c 100644 --- a/src/editors/containers/EditorContainer/index.test.tsx +++ b/src/editors/containers/EditorContainer/index.test.tsx @@ -11,6 +11,13 @@ import editorCmsApi from '../../data/services/cms/api'; import EditorPage from '../../EditorPage'; import * as hooks from './hooks'; +jest.mock('../../../assistant/context/hooks'); + +jest.mock('../../../assistant/AIAssistantWidget', () => ({ + __esModule: true, + default: () =>
, +})); + // Mock this plugins component: jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: diff --git a/src/editors/containers/EditorContainer/index.tsx b/src/editors/containers/EditorContainer/index.tsx index 8495796d53..1e48cd9ce3 100644 --- a/src/editors/containers/EditorContainer/index.tsx +++ b/src/editors/containers/EditorContainer/index.tsx @@ -23,6 +23,7 @@ import libraryMessages from '../../../library-authoring/add-content/messages'; import './index.scss'; import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty'; import CancelConfirmModal from './components/CancelConfirmModal'; +import AIAssistantWidget from '../../../assistant/AIAssistantWidget'; interface WrapperProps { children: React.ReactNode; @@ -130,12 +131,17 @@ const EditorContainer: React.FC = ({

- +
+ + +
diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index 1f8c21c76f..2e35f8426c 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -59,7 +59,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" />
@@ -235,7 +235,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = ` maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" />
@@ -301,7 +301,7 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = ` maxHeight={500} minHeight={500} onChange={[Function]} - setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} + setEditorRef={[Function]} studioEndpointUrl="" />
diff --git a/src/editors/containers/TextEditor/index.jsx b/src/editors/containers/TextEditor/index.jsx index 799740a356..b2d73b1226 100644 --- a/src/editors/containers/TextEditor/index.jsx +++ b/src/editors/containers/TextEditor/index.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; @@ -18,6 +18,7 @@ import * as hooks from './hooks'; import messages from './messages'; import TinyMceWidget from '../../sharedComponents/TinyMceWidget'; import { prepareEditorRef, replaceStaticWithAsset } from '../../sharedComponents/TinyMceWidget/hooks'; +import { useAiAssistant } from '../../../assistant/context/hooks'; const TextEditor = ({ onClose, @@ -35,8 +36,38 @@ const TextEditor = ({ // inject intl, }) => { - const { editorRef, refReady, setEditorRef } = prepareEditorRef(); - const initialContent = blockValue ? blockValue.data.data : ''; + const { aiData } = useAiAssistant(); + const aiGeneratedContent = aiData?.content ?? aiData; + + const { editorRef, refReady, setEditorRef: internalSetEditorRef } = prepareEditorRef(); + + const setEditorRef = useCallback((ref) => { + internalSetEditorRef(ref); + }, [internalSetEditorRef]); + + const contentFromBlock = blockValue?.data?.data || ''; + const initialContent = contentFromBlock; + + useEffect(() => { + const isEditorReady = showRawEditor + ? !!editorRef.current + : (refReady && !!editorRef.current); + + if (aiGeneratedContent && isEditorReady) { + if (showRawEditor) { + editorRef.current.dispatch({ + changes: { + from: 0, + to: editorRef.current.state.doc.length, + insert: aiGeneratedContent, + }, + }); + } else { + editorRef.current.setContent(aiGeneratedContent); + } + } + }, [aiGeneratedContent, refReady, showRawEditor, editorRef, aiData]); + const newContent = replaceStaticWithAsset({ initialContent, learningContextId, diff --git a/src/editors/containers/TextEditor/index.test.jsx b/src/editors/containers/TextEditor/index.test.jsx index 2b386b3bb8..7675f04ba2 100644 --- a/src/editors/containers/TextEditor/index.test.jsx +++ b/src/editors/containers/TextEditor/index.test.jsx @@ -76,6 +76,8 @@ jest.mock('../../data/redux', () => ({ }, })); +jest.mock('../../../assistant/context/hooks'); + describe('TextEditor', () => { const props = { onClose: jest.fn().mockName('props.onClose'), diff --git a/src/editors/sharedComponents/CodeEditor/hooks.js b/src/editors/sharedComponents/CodeEditor/hooks.js index a15e64bf15..9fb92ca1bc 100644 --- a/src/editors/sharedComponents/CodeEditor/hooks.js +++ b/src/editors/sharedComponents/CodeEditor/hooks.js @@ -115,13 +115,25 @@ export const createCodeMirrorDomNode = ({ ], }); const view = new EditorView({ state: newState, parent: ref.current }); - // eslint-disable-next-line no-param-reassign - upstreamRef.current = view; + + if (typeof upstreamRef === 'function') { + upstreamRef(view); + } else if (upstreamRef) { + // eslint-disable-next-line no-param-reassign + upstreamRef.current = view; + } + view.focus(); return () => { // called on cleanup view.destroy(); + if (typeof upstreamRef === 'function') { + upstreamRef(null); + } else if (upstreamRef) { + // eslint-disable-next-line no-param-reassign + upstreamRef.current = null; + } }; }, []); }; diff --git a/src/index.scss b/src/index.scss index c5d9bcb769..8dfac35824 100644 --- a/src/index.scss +++ b/src/index.scss @@ -32,6 +32,7 @@ @import "certificates/scss/Certificates"; @import "group-configurations/GroupConfigurations"; @import "optimizer-page/scan-results/ScanResults"; +@import "assistant/AIAssistantChat/AIAssistantChat"; // To apply the glow effect to the selected Section/Subsection, in the Course Outline div.row:has(> div > div.highlight) { diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 09f01bd174..eab893dc4e 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -32,6 +32,8 @@ import * as textEditorHooks from '../../editors/containers/TextEditor/hooks'; // Mocks for ComponentEditorModal to work in tests. jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); +jest.mock('../../assistant/context/hooks'); + const { libraryId } = mockContentLibrary; const render = (collectionId?: string) => { const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId }; diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx index 8244fc7167..364de33f71 100644 --- a/src/library-authoring/add-content/AddContentWorkflow.test.tsx +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -23,6 +23,8 @@ import { studioHomeMock } from '../../studio-home/__mocks__'; import { getStudioHomeApiUrl } from '../../studio-home/data/api'; import LibraryLayout from '../LibraryLayout'; +jest.mock('../../assistant/context/hooks'); + mockContentSearchConfig.applyMock(); mockClipboardEmpty.applyMock(); mockContentLibrary.applyMock(); diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index 18671ca562..0ef0a43d49 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -30,6 +30,8 @@ import { ToastActionData } from '../../generic/toast-context'; const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; +jest.mock('../../assistant/context/hooks'); + let axiosMock: MockAdapter; let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;