diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index bf371042c9..180070ef34 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -45,6 +45,7 @@ export const CONTAINER_INFO_TABS = { Manage: 'manage', Usage: 'usage', Settings: 'settings', + Details: 'details', } as const; export type ContainerInfoTab = typeof CONTAINER_INFO_TABS[keyof typeof CONTAINER_INFO_TABS]; export const isContainerInfoTab = (tab: string): tab is ContainerInfoTab => ( diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 127e3bc2ff..7aed5b9da8 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -4,13 +4,18 @@ import { render as baseRender, screen, fireEvent, + findByDeepTextContent, } from '@src/testUtils'; import { mockFetchIndexDocuments, mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { mockContentLibrary, mockGetEntityLinks, + mockLibraryBlockCreationEntry, + mockLibraryBlockDraftHistory, mockLibraryBlockMetadata, + mockLibraryBlockPublishHistory, + mockLibraryBlockPublishHistoryEntries, mockXBlockAssets, mockXBlockOLX, } from '../data/api.mocks'; @@ -21,6 +26,10 @@ import ComponentDetails from './ComponentDetails'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockLibraryBlockCreationEntry.applyMock(); +mockLibraryBlockDraftHistory.applyMock(); +mockLibraryBlockPublishHistory.applyMock(); +mockLibraryBlockPublishHistoryEntries.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); mockGetEntityLinks.applyMock(); @@ -60,7 +69,9 @@ describe('', () => { it('should render the component details error', async () => { render(mockLibraryBlockMetadata.usageKeyError404); - expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument(); + // Metadata and history queries fail silently; the section renders empty without crashing + expect(await screen.findByText('Component History')).toBeInTheDocument(); + expect(screen.queryByText(/Mocked request failed/)).not.toBeInTheDocument(); }); it('should render the component usage', async () => { @@ -96,11 +107,7 @@ describe('', () => { it('should render the component history', async () => { render(mockLibraryBlockMetadata.usageKeyPublished); - // Show created date - expect(await screen.findByText('June 20, 2024')).toBeInTheDocument(); - // Show modified date - expect(await screen.findByText('June 21, 2024')).toBeInTheDocument(); - // Show last published date - expect(await screen.findByText('June 22, 2024')).toBeInTheDocument(); + // usageKeyPublished matches usageKeyEmpty in mockLibraryBlockCreationEntry (TEST2 key) + expect(await findByDeepTextContent(/Author created.*Introduction to Testing 2/i)).toBeInTheDocument(); }); }); diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index 965e1235e5..11af3d13f0 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -1,14 +1,11 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Stack } from '@openedx/paragon'; -import AlertError from '../../generic/alert-error'; -import Loading from '../../generic/Loading'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useLibraryBlockMetadata } from '../data/apiHooks'; -import HistoryWidget from '../generic/history-widget'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; import { ComponentUsage } from './ComponentUsage'; import messages from './messages'; +import { HistoryComponentLog } from '../generic/history-log/HistoryLog'; const ComponentDetails = () => { const { sidebarItemInfo } = useSidebarContext(); @@ -20,21 +17,6 @@ const ComponentDetails = () => { throw new Error('usageKey is required'); } - const { - data: componentMetadata, - isError, - error, - isPending, - } = useLibraryBlockMetadata(usageKey); - - if (isError) { - return ; - } - - if (isPending) { - return ; - } - return ( <> @@ -48,7 +30,9 @@ const ComponentDetails = () => {

- +
diff --git a/src/library-authoring/containers/ContainerDetails.tsx b/src/library-authoring/containers/ContainerDetails.tsx new file mode 100644 index 0000000000..2019533cf8 --- /dev/null +++ b/src/library-authoring/containers/ContainerDetails.tsx @@ -0,0 +1,23 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { HistoryContainerLog } from '../generic/history-log/HistoryLog'; +import { useSidebarContext } from '../common/context/SidebarContext'; + +export const ContainerDetails = () => { + const intl = useIntl(); + + const { sidebarItemInfo } = useSidebarContext(); + + const usageKey = sidebarItemInfo?.id; + + return ( + <> +

{intl.formatMessage(messages.detailsTabHistoryHeading)}

+ {usageKey && ( + + )} + + ); +}; diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 04ef490a27..cf7f0cc074 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -34,6 +34,7 @@ import { useContainer } from '../data/apiHooks'; import ContainerDeleter from './ContainerDeleter'; import { ContainerPublisher } from './ContainerPublisher'; import { PublishDraftButton, PublishedChip } from '../generic/publish-status-buttons'; +import { ContainerDetails } from './ContainerDetails'; type ContainerPreviewProps = { containerId: string; @@ -239,6 +240,11 @@ const ContainerInfo = () => { intl.formatMessage(messages.settingsTabTitle), , )} + {renderTab( + CONTAINER_INFO_TABS.Details, + intl.formatMessage(messages.detailsTabTitle), + , + )} ); diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index b4cbbc6fdc..d77fddc8d4 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -41,6 +41,16 @@ const messages = defineMessages({ defaultMessage: 'Settings', description: 'Title for settings tab', }, + detailsTabTitle: { + id: 'course-authoring.library-authoring.container-sidebar.details-tab.title', + defaultMessage: 'Details', + description: 'Title for details tab', + }, + detailsTabHistoryHeading: { + id: 'course-authoring.library-authoring.container-sidebar.details-tab.history-heading', + defaultMessage: 'History', + description: 'Heading for details tab history section', + }, updateContainerSuccessMsg: { id: 'course-authoring.library-authoring.update-container-success-msg', defaultMessage: 'Container updated successfully.', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 8842457a0f..3507ec6ca7 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -258,6 +258,7 @@ mockCreateLibraryBlock.newHtmlData = { publishedBy: null, // or e.g. 'test_author', lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, + createdBy: null, created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, @@ -273,6 +274,7 @@ mockCreateLibraryBlock.newProblemData = { publishedBy: null, // or e.g. 'test_author', lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, + createdBy: null, created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, @@ -288,6 +290,7 @@ mockCreateLibraryBlock.newVideoData = { publishedBy: null, // or e.g. 'test_author', lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, + createdBy: null, created: '2024-07-22T21:37:49Z', modified: '2024-07-22T21:37:49Z', tagsCount: 0, @@ -459,6 +462,7 @@ mockLibraryBlockMetadata.dataNeverPublished = { lastDraftCreated: null, lastDraftCreatedBy: null, hasUnpublishedChanges: true, + createdBy: null, created: '2024-06-20T13:54:21Z', modified: '2024-06-21T13:54:21Z', tagsCount: 0, @@ -478,6 +482,7 @@ mockLibraryBlockMetadata.dataPublished = { created: '2024-06-20T13:54:21Z', modified: '2024-06-21T13:54:21Z', tagsCount: 0, + createdBy: null, collections: [], } satisfies api.LibraryBlockMetadata; mockLibraryBlockMetadata.usageKeyPublishDisabled = 'lb:Axim:TEST2-disabled:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; @@ -504,6 +509,7 @@ mockLibraryBlockMetadata.dataWithCollections = { lastDraftCreated: null, lastDraftCreatedBy: '2024-06-20T20:00:00Z', hasUnpublishedChanges: false, + createdBy: null, created: '2024-06-20T13:54:21Z', modified: '2024-06-21T13:54:21Z', tagsCount: 0, @@ -521,6 +527,7 @@ mockLibraryBlockMetadata.dataPublishedWithChanges = { lastDraftCreated: null, lastDraftCreatedBy: '2024-06-20T20:00:00Z', hasUnpublishedChanges: true, + createdBy: null, created: '2024-06-20T13:54:21Z', modified: '2024-06-23T13:54:21Z', tagsCount: 0, @@ -741,6 +748,7 @@ mockGetContainerChildren.childTemplate = { publishedBy: null, lastDraftCreated: null, lastDraftCreatedBy: null, + createdBy: null, hasUnpublishedChanges: false, created: null, modified: null, @@ -1229,6 +1237,270 @@ mockGetCourseImports.applyMock = () => 'getCourseImports', ).mockImplementation(mockGetCourseImports); +/** + * Mock for `getLibraryBlockDraftHistory()` + * + * Use `mockLibraryBlockDraftHistory.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryBlockDraftHistory(usageKey: string): Promise { + const thisMock = mockLibraryBlockDraftHistory; + switch (usageKey) { + case thisMock.usageKey: + return thisMock.data; + case thisMock.usageKeyEmpty: + return []; + default: + throw new Error(`No mock has been set up for usageKey "${usageKey}"`); + } +} +mockLibraryBlockDraftHistory.usageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryBlockDraftHistory.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +const mockContributor = (username: string): api.LibraryPublishContributor => ({ + username, + profileImageUrls: { + full: 'icon/mock/path', + large: 'icon/mock/path', + medium: 'icon/mock/path', + small: 'icon/mock/path', + }, +}); + +mockLibraryBlockDraftHistory.data = [ + { + contributor: mockContributor('test_user_1'), + changedAt: '2026-03-16T11:00:00Z', + title: 'Electron Arcs', + action: 'edited', + itemType: 'html', + }, + { + contributor: mockContributor('test_user_2'), + changedAt: '2026-03-13T10:00:00Z', + title: 'More on Quarks', + action: 'renamed', + itemType: 'html', + }, +] satisfies api.LibraryHistoryEntry[]; +mockLibraryBlockDraftHistory.applyMock = () => + jest.spyOn(api, 'getLibraryBlockDraftHistory').mockImplementation(mockLibraryBlockDraftHistory); + +/** + * Mock for `getLibraryBlockPublishHistory()` + * + * Use `mockLibraryBlockPublishHistory.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryBlockPublishHistory(usageKey: string): Promise { + const thisMock = mockLibraryBlockPublishHistory; + switch (usageKey) { + case thisMock.usageKeyWithGroups: + return thisMock.data; + case thisMock.usageKeyEmpty: + return []; + default: + throw new Error(`No mock has been set up for usageKey "${usageKey}"`); + } +} +mockLibraryBlockPublishHistory.usageKeyWithGroups = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryBlockPublishHistory.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +mockLibraryBlockPublishHistory.data = [ + { + publishLogUuid: 'abc-123', + directPublishedEntities: [ + { entityKey: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1', entityType: 'html', title: 'Protons' }, + ], + publishedBy: 'author', + publishedAt: '2026-03-14T10:00:00Z', + contributors: ['test_user_1', 'test_user_2', 'test_user_3', 'test_user_4', 'test_user_5'].map(mockContributor), + contributorsCount: 5, + }, +] as api.LibraryPublishHistoryGroup[]; +mockLibraryBlockPublishHistory.applyMock = () => + jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory); + +/** + * Mock for `getLibraryPublishHistoryEntries()` + * + * Use `mockLibraryBlockPublishHistoryEntries.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryBlockPublishHistoryEntries( + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + ..._: Parameters +): Promise { + return mockLibraryBlockPublishHistoryEntries.data; +} +mockLibraryBlockPublishHistoryEntries.data = [ + { + contributor: mockContributor('test_user'), + changedAt: '2026-03-10T09:00:00Z', + title: 'Protons', + action: 'edited', + itemType: 'html', + }, +] satisfies api.LibraryHistoryEntry[]; +mockLibraryBlockPublishHistoryEntries.applyMock = () => + jest.spyOn( + api, + 'getLibraryPublishHistoryEntries', + ).mockImplementation(mockLibraryBlockPublishHistoryEntries); + +/** + * Mock for `getLibraryBlockCreationEntry()` + * + * Use `mockLibraryBlockCreationEntry.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryBlockCreationEntry(usageKey: string): Promise { + const thisMock = mockLibraryBlockCreationEntry; + switch (usageKey) { + case thisMock.usageKeyThatNeverLoads: + return new Promise(() => {}); + case thisMock.usageKey: + return thisMock.data; + case thisMock.usageKeyEmpty: + return thisMock.dataEmpty; + default: + throw new Error(`No mock has been set up for usageKey "${usageKey}"`); + } +} +mockLibraryBlockCreationEntry.usageKeyThatNeverLoads = 'lb:Axim:infiniteLoading:html:123'; +mockLibraryBlockCreationEntry.usageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryBlockCreationEntry.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +mockLibraryBlockCreationEntry.data = { + contributor: mockContributor('author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing 1', + itemType: 'html', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryBlockCreationEntry.dataEmpty = { + contributor: mockContributor('Author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing 2', + itemType: 'html', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryBlockCreationEntry.applyMock = () => + jest.spyOn(api, 'getLibraryBlockCreationEntry').mockImplementation(mockLibraryBlockCreationEntry); + +/** + * Mock for `getLibraryContainerDraftHistory()` + * + * Use `mockLibraryContainerDraftHistory.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryContainerDraftHistory(containerKey: string): Promise { + const thisMock = mockLibraryContainerDraftHistory; + switch (containerKey) { + case thisMock.containerKeyThatNeverLoads: + return new Promise(() => {}); + case thisMock.containerKey: + return thisMock.data; + case thisMock.containerKeyEmpty: + return []; + default: + throw new Error(`No mock has been set up for containerKey "${containerKey}"`); + } +} +mockLibraryContainerDraftHistory.containerKeyThatNeverLoads = 'lct:Axim:TEST1:unit:infiniteLoading'; +mockLibraryContainerDraftHistory.containerKey = 'lct:Axim:TEST1:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryContainerDraftHistory.containerKeyEmpty = 'lct:Axim:TEST2:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +mockLibraryContainerDraftHistory.data = [ + { + contributor: mockContributor('container_user_1'), + changedAt: '2026-03-16T11:00:00Z', + title: 'Intro Unit', + action: 'edited', + itemType: 'unit', + }, + { + contributor: mockContributor('container_user_2'), + changedAt: '2026-03-13T10:00:00Z', + title: 'Unit Renamed', + action: 'renamed', + itemType: 'unit', + }, +] satisfies api.LibraryHistoryEntry[]; +mockLibraryContainerDraftHistory.applyMock = () => + jest.spyOn(api, 'getLibraryContainerDraftHistory').mockImplementation(mockLibraryContainerDraftHistory); + +/** + * Mock for `getLibraryContainerPublishHistory()` + * + * Use `mockLibraryContainerPublishHistory.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryContainerPublishHistory( + containerKey: string, +): Promise { + const thisMock = mockLibraryContainerPublishHistory; + switch (containerKey) { + case thisMock.containerKeyThatNeverLoads: + return new Promise(() => {}); + case thisMock.containerKeyWithGroups: + return thisMock.data; + case thisMock.containerKeyEmpty: + return []; + default: + throw new Error(`No mock has been set up for containerKey "${containerKey}"`); + } +} +mockLibraryContainerPublishHistory.containerKeyThatNeverLoads = 'lct:Axim:TEST1:unit:infiniteLoading'; +mockLibraryContainerPublishHistory.containerKeyWithGroups = 'lct:Axim:TEST1:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryContainerPublishHistory.containerKeyEmpty = 'lct:Axim:TEST2:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +mockLibraryContainerPublishHistory.data = [ + { + publishLogUuid: 'def-456', + directPublishedEntities: [ + { + entityKey: 'lct:Axim:TEST1:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd1', + entityType: 'unit', + title: 'Intro Unit', + }, + ], + publishedBy: 'container_author', + publishedAt: '2026-03-14T10:00:00Z', + contributors: ['container_user_1', 'container_user_2'].map(mockContributor), + contributorsCount: 2, + }, +] satisfies api.LibraryPublishHistoryGroup[]; +mockLibraryContainerPublishHistory.applyMock = () => + jest.spyOn(api, 'getLibraryContainerPublishHistory').mockImplementation(mockLibraryContainerPublishHistory); + +/** + * Mock for `getLibraryContainerCreationEntry()` + * + * Use `mockLibraryContainerCreationEntry.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryContainerCreationEntry(containerKey: string): Promise { + const thisMock = mockLibraryContainerCreationEntry; + switch (containerKey) { + case thisMock.usageKeyThatNeverLoads: + return new Promise(() => {}); + case thisMock.usageKey: + return thisMock.data; + case thisMock.usageKeyEmpty: + return thisMock.dataEmpty; + default: + throw new Error(`No mock has been set up for containerKey "${containerKey}"`); + } +} +mockLibraryContainerCreationEntry.usageKeyThatNeverLoads = 'lct:Axim:TEST1:unit:infiniteLoading'; +mockLibraryContainerCreationEntry.usageKey = 'lct:Axim:TEST1:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; +mockLibraryContainerCreationEntry.usageKeyEmpty = 'lct:Axim:TEST2:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; +mockLibraryContainerCreationEntry.data = { + contributor: mockContributor('author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing Unit 1', + itemType: 'unit', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryContainerCreationEntry.dataEmpty = { + contributor: mockContributor('Author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing Unit 2', + itemType: 'unit', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryContainerCreationEntry.applyMock = () => + jest.spyOn(api, 'getLibraryContainerCreationEntry').mockImplementation(mockLibraryContainerCreationEntry); + export const mockGetMigrationInfo = { applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue( diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index ece0773f4d..14c2815bc5 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -151,4 +151,90 @@ describe('library data API', () => { await api.getContentLibraryV2List({ type: 'complex' }); expect(axiosMock.history.get[0].url).toEqual(url); }); + + describe('getLibraryBlockDraftHistory', () => { + it('should fetch draft history for a library block', async () => { + const usageKey = 'lb:org:lib:html:1'; + const url = api.getLibraryBlockDraftHistoryUrl(usageKey); + axiosMock.onGet(url).reply(200, []); + + await api.getLibraryBlockDraftHistory(usageKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryBlockPublishHistory', () => { + it('should fetch publish history groups for a library block', async () => { + const usageKey = 'lb:org:lib:html:1'; + const url = api.getLibraryBlockPublishHistoryUrl(usageKey); + axiosMock.onGet(url).reply(200, []); + + await api.getLibraryBlockPublishHistory(usageKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryPublishHistoryEntries', () => { + it('should fetch entries for a publish history group', async () => { + const libraryId = 'lib:org:lib1'; + const entityKey = 'lb:org:lib:html:1'; + const publishGroupId = 'abc-123'; + const url = api.getLibraryPublishHistoryEntriesUrl(libraryId, entityKey, publishGroupId); + axiosMock.onGet(url).reply(200, []); + + await api.getLibraryPublishHistoryEntries(libraryId, entityKey, publishGroupId); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryBlockCreationEntry', () => { + it('should fetch the creation entry for a library block', async () => { + const usageKey = 'lb:org:lib:html:1'; + const url = api.getLibraryBlockCreationEntryUrl(usageKey); + axiosMock.onGet(url).reply(200, {}); + + await api.getLibraryBlockCreationEntry(usageKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryContainerDraftHistory', () => { + it('should fetch draft history for a library container', async () => { + const containerKey = 'lct:org:lib:unit:1'; + const url = api.getLibraryContainerDraftHistoryUrl(containerKey); + axiosMock.onGet(url).reply(200, []); + + await api.getLibraryContainerDraftHistory(containerKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryContainerPublishHistory', () => { + it('should fetch publish history groups for a library container', async () => { + const containerKey = 'lct:org:lib:unit:1'; + const url = api.getLibraryContainerPublishHistoryUrl(containerKey); + axiosMock.onGet(url).reply(200, []); + + await api.getLibraryContainerPublishHistory(containerKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); + + describe('getLibraryContainerCreationEntry', () => { + it('should fetch the creation entry for a library container', async () => { + const containerKey = 'lct:org:lib:unit:1'; + const url = api.getLibraryContainerCreationEntryUrl(containerKey); + axiosMock.onGet(url).reply(200, {}); + + await api.getLibraryContainerCreationEntry(containerKey); + + expect(axiosMock.history.get[0].url).toEqual(url); + }); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 72c880558e..e2faf17506 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -33,6 +33,16 @@ export const getLibraryTeamMemberApiUrl = (libraryId: string, username: string) export const getBlockTypesMetaDataUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/block_types/`; +/** + * Get the URL for the entries of a publish group. + */ +export const getLibraryPublishHistoryEntriesUrl = ( + libraryId: string, + entityKey: string, + publishGroupId: string, +) => + `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/publish_history_entries/?scope_entity_key=${entityKey}&publish_log_uuid=${publishGroupId}`; + /** * Get the URL for library block metadata. */ @@ -55,6 +65,24 @@ export const getLibraryBlockCollectionsUrl = (usageKey: string) => */ export const getLibraryBlockHierarchyUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}hierarchy/`; +/** + * Get the URL for the component draft history. + */ +export const getLibraryBlockDraftHistoryUrl = (usageKey: string) => + `${getLibraryBlockMetadataUrl(usageKey)}draft_history/`; + +/** + * Get the URL for the component publish history. + */ +export const getLibraryBlockPublishHistoryUrl = (usageKey: string) => + `${getLibraryBlockMetadataUrl(usageKey)}publish_history/`; + +/** + * Get the URL for the creation entry of a component. + */ +export const getLibraryBlockCreationEntryUrl = (usageKey: string) => + `${getLibraryBlockMetadataUrl(usageKey)}creation_entry/`; + /** * Get the URL for content library list API. */ @@ -160,6 +188,21 @@ export const getLibraryContainerCollectionsUrl = (containerId: string) => */ export const getLibraryContainerPublishApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}publish/`; +/** + * Get the URL for the draft history log of a contaienr. + */ +export const getLibraryContainerDraftHistoryUrl = (containerId: string) => + `${getLibraryContainerApiUrl(containerId)}draft_history/`; +/** + * Get the URL for the publish history log of a container. + */ +export const getLibraryContainerPublishHistoryUrl = (containerId: string) => + `${getLibraryContainerApiUrl(containerId)}publish_history/`; +/** + * Get the URL for the creation entry of a container. + */ +export const getLibraryContainerCreationEntryUrl = (usageKey: string) => + `${getLibraryContainerApiUrl(usageKey)}creation_entry/`; /** * Get the URL for the API endpoint to create a backup of a v2 library. */ @@ -332,6 +375,7 @@ export interface LibraryBlockMetadata { lastDraftCreatedBy: string | null; hasUnpublishedChanges: boolean; created: string | null; + createdBy: string | null; modified: string | null; tagsCount: number; collections: CollectionMetadata[]; @@ -932,3 +976,104 @@ export async function getModulestoreMigrationBlocksInfo( const { data } = await client.get(getModulestoreMigratedBlocksInfoUrl(), { params }); return camelCaseObject(data); } + +export interface DirectPublishedEntity { + entityKey: string; + entityType: string; + title: string; +} +export interface LibraryPublishHistoryGroup { + publishLogUuid: string; + directPublishedEntities: DirectPublishedEntity[]; + publishedBy?: string; + publishedAt: string; + contributors: LibraryPublishContributor[]; + contributorsCount: number; + /** + * Key to use as `scope_entity_key` when fetching entries for this group. + * Pre-Verawood: the specific entity key for this group (container or usage key). + * Post-Verawood container groups: null — use the container currently being viewed. + * Component history (all eras): the component's usage key. + */ + scopeEntityKey?: string; +} + +export interface LibraryPublishContributor { + profileImageUrls: { + full: string; + large: string; + medium: string; + small: string; + }; + username?: string; +} + +export interface LibraryHistoryEntry { + contributor: LibraryPublishContributor; + changedAt: string; + title: string; + itemType: string; + action: 'edited' | 'renamed' | 'created'; +} + +/** + * Get the publish history for a library block. + */ +export async function getLibraryBlockPublishHistory(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockPublishHistoryUrl(usageKey)); + return camelCaseObject(data); +} + +/** + * Get the entries for a publish history group of a library block. + */ +export async function getLibraryPublishHistoryEntries( + libraryId: string, + entityKey: string, + publishGroupId: string, +): Promise { + const { data } = await getAuthenticatedHttpClient().get( + getLibraryPublishHistoryEntriesUrl(libraryId, entityKey, publishGroupId), + ); + return camelCaseObject(data); +} + +/** + * Get the draft history for a library block. + */ +export async function getLibraryBlockDraftHistory(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockDraftHistoryUrl(usageKey)); + return camelCaseObject(data); +} + +/** + * Get the creation entry for a library block. + */ +export async function getLibraryBlockCreationEntry(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockCreationEntryUrl(usageKey)); + return camelCaseObject(data); +} + +/** + * Get the creation entry for a library container. + */ +export async function getLibraryContainerCreationEntry(containerKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerCreationEntryUrl(containerKey)); + return camelCaseObject(data); +} + +/** + * Get the publish history for a library container. + */ +export async function getLibraryContainerPublishHistory(containerKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerPublishHistoryUrl(containerKey)); + return camelCaseObject(data); +} + +/** + * Get the draft history for a library container. + */ +export async function getLibraryContainerDraftHistory(containerKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryContainerDraftHistoryUrl(containerKey)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 6a8d97c5cd..ff12390dae 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -91,6 +91,18 @@ export const libraryAuthoringQueryKeys = { } return ['hierarchy']; }, + containerCreationEntry: (containerId: string) => [ + ...libraryAuthoringQueryKeys.container(containerId), + 'creationEntry', + ], + containerDraftHistory: (containerId: string) => [ + ...libraryAuthoringQueryKeys.container(containerId), + 'draftHistory', + ], + containerPublishHistory: (containerId: string) => [ + ...libraryAuthoringQueryKeys.container(containerId), + 'publishHistory', + ], courseImports: (libraryId: string) => [ ...libraryAuthoringQueryKeys.contentLibrary(libraryId), 'courseImports', @@ -125,6 +137,13 @@ export const xblockQueryKeys = { xblockAssets: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'assets'], componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'], componentDownstreamLinks: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'downstreamLinks'], + draftHistory: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'draftHistory'], + publishHistory: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory'], + publishHistoryEntries: ( + usageKey: string, + publishGroupId: string, + ) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory', publishGroupId, 'entries'], + creationEntry: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'creationEntry'], /** * Predicate used to invalidate all metadata only (not OLX, fields, assets, etc.). @@ -132,6 +151,8 @@ export const xblockQueryKeys = { * introspecting the usage keys. */ allComponentMetadata: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'componentMetadata', + allDraftHistory: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'draftHistory', + allPublishHistory: (query: Query) => query.queryKey[0] === 'xblock' && query.queryKey[2] === 'publishHistory', componentHierarchy: (usageKey?: string) => { if (usageKey) { return [ @@ -161,6 +182,24 @@ export function invalidateComponentData(queryClient: QueryClient, contentLibrary // This might fail in case this helper is called after deleting the block. queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) }); + queryClient.invalidateQueries({ queryKey: xblockQueryKeys.draftHistory(usageKey) }); + queryClient.invalidateQueries({ queryKey: xblockQueryKeys.publishHistory(usageKey) }); +} + +/** + * Tell react-query to refresh its cache of component-related data across all components in all libraries. + * + * Use this when a bulk operation (e.g. publish all, revert all) affects an unknown set of components + * and it's not practical to invalidate them individually. + * + * @param queryClient The query client - get it via useQueryClient() + */ +export function invalidateAllComponentData(queryClient: QueryClient) { + // For XBlocks, the only thing we need to invalidate is the metadata which includes "has unpublished changes" + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allComponentMetadata }); + // For XBlocks, to invalidate the history log queries to refresh the history + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allDraftHistory }); + queryClient.invalidateQueries({ predicate: xblockQueryKeys.allPublishHistory }); } /** @@ -275,8 +314,7 @@ export const useCommitLibraryChanges = () => { // 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 }); + invalidateAllComponentData(queryClient); }, }); }; @@ -290,8 +328,7 @@ export const useRevertLibraryChanges = () => { // 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 }); + invalidateAllComponentData(queryClient); }, }); }; @@ -961,8 +998,7 @@ export const usePublishContainer = (containerId: string) => { queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryContent(libraryId) }); queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(containerId) }); 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 }); + invalidateAllComponentData(queryClient); }, }); }; @@ -1014,6 +1050,82 @@ export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true) }) ); +/** + * Returns the draft history of a library block. + */ +export const useLibraryBlockDraftHistory = (usageKey?: string) => ( + useQuery({ + queryKey: xblockQueryKeys.draftHistory(usageKey!), + queryFn: usageKey ? () => api.getLibraryBlockDraftHistory(usageKey) : skipToken, + }) +); + +/** + * Returns the publish history of a library block. + */ +export const useLibraryBlockPublishHistory = (usageKey?: string) => ( + useQuery({ + queryKey: xblockQueryKeys.publishHistory(usageKey!), + queryFn: usageKey ? () => api.getLibraryBlockPublishHistory(usageKey) : skipToken, + }) +); + +/** + * Returns the entries for a publish history group of a library item. + */ +export const useLibraryPublishHistoryEntries = ( + usageKey?: string, + publishGroupId?: string, + enabled: boolean = true, +) => ( + useQuery({ + queryKey: xblockQueryKeys.publishHistoryEntries(usageKey!, publishGroupId!), + queryFn: (usageKey && publishGroupId && enabled) + ? () => api.getLibraryPublishHistoryEntries(getLibraryId(usageKey), usageKey, publishGroupId) + : skipToken, + }) +); + +/** + * Returns the creation entry for a library block. + */ +export const useLibraryBlockCreationEntry = (usageKey?: string) => ( + useQuery({ + queryKey: xblockQueryKeys.creationEntry(usageKey!), + queryFn: usageKey ? () => api.getLibraryBlockCreationEntry(usageKey) : skipToken, + }) +); + +/** + * Hook to fetch the publish history groups for a library container (unit, section, subsection). + */ +export const useLibraryContainerPublishHistory = (containerKey?: string) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.containerPublishHistory(containerKey!), + queryFn: containerKey ? () => api.getLibraryContainerPublishHistory(containerKey) : skipToken, + }) +); + +/** + * Hook to fetch the draft history entries for a library container (unit, section, subsection). + */ +export const useLibraryContainerDraftHistory = (containerKey?: string) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.containerDraftHistory(containerKey!), + queryFn: containerKey ? () => api.getLibraryContainerDraftHistory(containerKey) : skipToken, + }) +); + +/** + * Hook to fetch the creation entry for a library container (unit, section, subsection). + */ +export const useLibraryContainerCreationEntry = (containerKey?: string) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.containerCreationEntry(containerKey!), + queryFn: containerKey ? () => api.getLibraryContainerCreationEntry(containerKey) : skipToken, + }) +); + /** * Returns the migration blocks info of a given library */ diff --git a/src/library-authoring/generic/history-log/HistoryLog.scss b/src/library-authoring/generic/history-log/HistoryLog.scss new file mode 100644 index 0000000000..91276983b9 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLog.scss @@ -0,0 +1,56 @@ +.history-log { + .history-log-group { + .history-log-group-avatar { + &.big-avatar { + border: 3px solid; + } + + &.small-avatar { + border: 2px solid; + } + } + + .history-log-title { + max-width: 250px; + } + + .history-log-vert { + width: 8px; + height: 2rem; + margin: -10px 0 -10px 20px; + + &.history-log-vert-long { + height: 3.3rem; + } + } + + &.draft-group { + .history-log-group-avatar { + border-color: #B4610E; + } + + .history-log-vert { + background-color: #B4610E; + } + } + + .contributors-avatars { + display: flex; + align-items: center; + + .contributors-avatar { + margin-left: -.5rem; + } + } + + &.publish-group { + .history-log-group-avatar { + border-color: var(--pgn-color-info-400); + } + + .history-log-vert { + background-color: var(--pgn-color-info-400); + } + } + } +} diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx new file mode 100644 index 0000000000..dbf702c078 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -0,0 +1,253 @@ +import { userEvent } from '@testing-library/user-event'; +import { + initializeMocks, + render, + screen, + waitFor, + findByDeepTextContent, +} from '@src/testUtils'; + +import type { LibraryPublishContributor } from '@src/library-authoring/data/api'; +import { + mockLibraryBlockDraftHistory, + mockLibraryBlockPublishHistory, + mockLibraryBlockPublishHistoryEntries, + mockLibraryBlockCreationEntry, + mockLibraryBlockMetadata, + mockLibraryContainerDraftHistory, + mockLibraryContainerPublishHistory, + mockLibraryContainerCreationEntry, + mockGetContainerMetadata, +} from '@src/library-authoring/data/api.mocks'; +import { HistoryComponentLog, HistoryContainerLog } from './HistoryLog'; + +mockLibraryBlockDraftHistory.applyMock(); +mockLibraryBlockPublishHistory.applyMock(); +mockLibraryBlockPublishHistoryEntries.applyMock(); +mockLibraryBlockCreationEntry.applyMock(); +mockLibraryBlockMetadata.applyMock(); +mockLibraryContainerDraftHistory.applyMock(); +mockLibraryContainerPublishHistory.applyMock(); +mockLibraryContainerCreationEntry.applyMock(); +mockGetContainerMetadata.applyMock(); + +const renderComponent = (componentId: string) => + render( + , + ); + +const renderContainerComponent = (containerId: string) => + render( + , + ); + +const mockContributorNoUsername = (): LibraryPublishContributor => ({ + profileImageUrls: { + full: 'http://example.com/full.png', + large: 'http://example.com/large.png', + medium: 'http://example.com/medium.png', + small: 'http://example.com/small.png', + }, +}); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('shows loading spinner while fetching', () => { + renderComponent(mockLibraryBlockCreationEntry.usageKeyThatNeverLoads); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('renders draft history group with entries when they exist', async () => { + const user = userEvent.setup(); + renderComponent(mockLibraryBlockCreationEntry.usageKey); + const trigger = await findByDeepTextContent(/Introduction to Testing 1 is a draft/i); + expect(trigger).toBeInTheDocument(); + await user.click(trigger); + expect(await findByDeepTextContent(/test_user_1 edited.*Electron Arcs/i)).toBeInTheDocument(); + expect(await findByDeepTextContent(/test_user_2 renamed.*More on Quarks/i)).toBeInTheDocument(); + }); + + it('does not render draft history group when there are no draft entries', async () => { + renderComponent(mockLibraryBlockCreationEntry.usageKeyEmpty); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByText(/is a draft/i)).not.toBeInTheDocument(); + }); + + it('renders publish history group when one exists', async () => { + renderComponent(mockLibraryBlockCreationEntry.usageKey); + expect(await findByDeepTextContent(/author published.*Protons/i)).toBeInTheDocument(); + expect(await screen.findByText(/5 authors contributed/i)).toBeInTheDocument(); + }); + + it('loads and shows publish history entries after expanding the publish group', async () => { + const user = userEvent.setup(); + renderComponent(mockLibraryBlockCreationEntry.usageKey); + expect(await screen.findByText(/5 authors contributed/i)).toBeInTheDocument(); + const publishTrigger = await findByDeepTextContent(/author published.*Protons/i); + + await user.click(publishTrigger); + expect(await findByDeepTextContent(/test_user edited.*Protons/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(/5 authors contributed/i)).not.toBeInTheDocument()); + }); + + it('does not render publish history group when list is empty', async () => { + renderComponent(mockLibraryBlockCreationEntry.usageKeyEmpty); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByText(/published/i)).not.toBeInTheDocument(); + }); + + it('always renders the created group with fallback user when createdBy is null', async () => { + renderComponent(mockLibraryBlockCreationEntry.usageKey); + expect(await findByDeepTextContent(/Author created.*Introduction to Testing 1/i)).toBeInTheDocument(); + }); + + it('shows fallback "Author" for draft entry when contributor has no username', async () => { + const user = userEvent.setup(); + const originalData = mockLibraryBlockDraftHistory.data; + mockLibraryBlockDraftHistory.data = [ + { + contributor: mockContributorNoUsername(), + changedAt: '2026-03-16T11:00:00Z', + title: 'Anonymous Component', + itemType: 'html', + action: 'edited', + }, + ]; + renderComponent(mockLibraryBlockCreationEntry.usageKey); + const trigger = await findByDeepTextContent(/Introduction to Testing 1 is a draft/i); + await user.click(trigger); + expect(await findByDeepTextContent(/Author edited.*Anonymous Component/i)).toBeInTheDocument(); + mockLibraryBlockDraftHistory.data = originalData; + }); + + it('shows fallback "Author" in publish group header when publishedBy is undefined', async () => { + const originalData = mockLibraryBlockPublishHistory.data; + mockLibraryBlockPublishHistory.data = [ + { + ...originalData[0], + publishedBy: undefined, + }, + ]; + renderComponent(mockLibraryBlockCreationEntry.usageKey); + expect(await findByDeepTextContent(/Author published.*Protons/i)).toBeInTheDocument(); + mockLibraryBlockPublishHistory.data = originalData; + }); + + it('renders draft entry with "created" action', async () => { + const user = userEvent.setup(); + const originalData = mockLibraryBlockDraftHistory.data; + mockLibraryBlockDraftHistory.data = [ + { + contributor: { + username: 'creator_user', + profileImageUrls: { + full: 'icon/mock/path', + large: 'icon/mock/path', + medium: 'icon/mock/path', + small: 'icon/mock/path', + }, + }, + changedAt: '2026-03-16T11:00:00Z', + title: 'New Component', + itemType: 'html', + action: 'created', + }, + ] as any; + renderComponent(mockLibraryBlockCreationEntry.usageKey); + const trigger = await findByDeepTextContent(/Introduction to Testing 1 is a draft/i); + await user.click(trigger); + expect(await findByDeepTextContent(/creator_user created.*New Component/i)).toBeInTheDocument(); + mockLibraryBlockDraftHistory.data = originalData; + }); + + it('renders publish group without collapsible and without contributor count when contributors is empty', async () => { + const originalData = mockLibraryBlockPublishHistory.data; + mockLibraryBlockPublishHistory.data = [ + { + ...originalData[0], + contributors: [], + contributorsCount: 0, + }, + ]; + renderComponent(mockLibraryBlockCreationEntry.usageKey); + const publishTitle = await findByDeepTextContent(/author published.*Protons/i); + expect(publishTitle).toBeInTheDocument(); + expect(screen.queryByText(/authors? contributed/i)).not.toBeInTheDocument(); + expect(publishTitle.closest('[role="button"]')).toBeNull(); + mockLibraryBlockPublishHistory.data = originalData; + }); +}); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('shows loading spinner while fetching', () => { + renderContainerComponent(mockLibraryContainerDraftHistory.containerKeyThatNeverLoads); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('renders draft history group with entries when they exist', async () => { + const user = userEvent.setup(); + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + const trigger = await findByDeepTextContent(/Test Unit is a draft/i); + expect(trigger).toBeInTheDocument(); + await user.click(trigger); + expect(await findByDeepTextContent(/container_user_1 edited.*Intro Unit/i)).toBeInTheDocument(); + expect(await findByDeepTextContent(/container_user_2 renamed.*Unit Renamed/i)).toBeInTheDocument(); + }); + + it('does not render draft history group when there are no draft entries', async () => { + renderContainerComponent(mockLibraryContainerDraftHistory.containerKeyEmpty); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByText(/is a draft/i)).not.toBeInTheDocument(); + }); + + it('renders publish history group when one exists', async () => { + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + expect(await findByDeepTextContent(/container_author published.*Intro Unit/i)).toBeInTheDocument(); + expect(await screen.findByText(/2 authors contributed/i)).toBeInTheDocument(); + }); + + it('does not render publish history group when list is empty', async () => { + renderContainerComponent(mockLibraryContainerDraftHistory.containerKeyEmpty); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByText(/published/i)).not.toBeInTheDocument(); + }); + + it('renders draft entry with "created" action', async () => { + const user = userEvent.setup(); + const originalData = mockLibraryContainerDraftHistory.data; + mockLibraryContainerDraftHistory.data = [ + { + contributor: { + username: 'creator_user', + profileImageUrls: { + full: 'icon/mock/path', + large: 'icon/mock/path', + medium: 'icon/mock/path', + small: 'icon/mock/path', + }, + }, + changedAt: '2026-03-16T11:00:00Z', + title: 'New Unit', + itemType: 'unit', + action: 'created', + }, + ] as any; + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + const trigger = await findByDeepTextContent(/Test Unit is a draft/i); + await user.click(trigger); + expect(await findByDeepTextContent(/creator_user created.*New Unit/i)).toBeInTheDocument(); + mockLibraryContainerDraftHistory.data = originalData; + }); + + it('always renders the created group', async () => { + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + expect(await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i)).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx new file mode 100644 index 0000000000..70baa6dd74 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -0,0 +1,122 @@ +import { LoadingSpinner } from '@src/generic/Loading'; +import { + useContainer, + useLibraryBlockCreationEntry, + useLibraryBlockDraftHistory, + useLibraryBlockMetadata, + useLibraryBlockPublishHistory, + useLibraryContainerCreationEntry, + useLibraryContainerDraftHistory, + useLibraryContainerPublishHistory, +} from '@src/library-authoring/data/apiHooks'; +import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } from './HistoryLogGroup'; + +export const HistoryComponentLog = ({ componentId }: { componentId: string; }) => { + const { + data: draftHistory, + isPending: isPendingDraftHistory, + } = useLibraryBlockDraftHistory(componentId); + + const { + data: publishHistoryGroups, + isPending: isPendingPublishHistoryGroups, + } = useLibraryBlockPublishHistory(componentId); + + const { + data: creationEntry, + isPending: isPendingCreationEntry, + } = useLibraryBlockCreationEntry(componentId); + + const { + data: componentMetadata, + isPending: isPendingComponentMetadata, + } = useLibraryBlockMetadata(componentId); + + if (isPendingDraftHistory || isPendingPublishHistoryGroups || isPendingCreationEntry || isPendingComponentMetadata) { + return ; + } + + return ( +
+ {draftHistory && draftHistory.length !== 0 && ( + + )} + {publishHistoryGroups && publishHistoryGroups.length !== 0 && ( + publishHistoryGroups.map((publishGroup) => ( +
+ +
+ )) + )} + {creationEntry && ( + + )} +
+ ); +}; + +export const HistoryContainerLog = ({ containerId }: { containerId: string; }) => { + const { + data: draftHistory, + isPending: isPendingDraftHistory, + } = useLibraryContainerDraftHistory(containerId); + + const { + data: publishHistoryGroups, + isPending: isPendingPublishHistoryGroups, + } = useLibraryContainerPublishHistory(containerId); + + const { + data: creationEntry, + isPending: isPendingCreationEntry, + } = useLibraryContainerCreationEntry(containerId); + + const { + data: container, + isPending: isPendingContainer, + } = useContainer(containerId); + + if (isPendingDraftHistory || isPendingContainer || isPendingPublishHistoryGroups || isPendingCreationEntry) { + return ; + } + + return ( +
+ {draftHistory && draftHistory.length !== 0 && ( + + )} + {publishHistoryGroups && publishHistoryGroups.length !== 0 && ( + publishHistoryGroups.map((publishGroup) => ( +
+ +
+ )) + )} + {creationEntry && ( + + )} +
+ ); +}; diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx new file mode 100644 index 0000000000..07f9d45106 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -0,0 +1,320 @@ +import { ComponentProps, ReactNode, useState } from 'react'; +import moment from 'moment'; +import classNames from 'classnames'; +import { Avatar, Collapsible, Icon, Stack, useToggle } from '@openedx/paragon'; +import { KeyboardArrowDown, KeyboardArrowUp } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; + +import { useLibraryPublishHistoryEntries } from '@src/library-authoring/data/apiHooks'; +import { LoadingSpinner } from '@src/generic/Loading'; + +import { LibraryHistoryEntry, LibraryPublishContributor, LibraryPublishHistoryGroup } from '../../data/api'; +import messages from './messages'; +import { getItemIcon } from '@src/generic/block-type-utils'; + +const MAX_VISIBLE_CONTRIBUTORS = 5; + +export interface HistoryLogGroupTitleProps { + titleMessage: string | ReactNode; + dateMessage: string; + disableCollapsible?: boolean; +} + +export interface HistoryCreatedLogGroupProps { + user?: string | null; + displayName: string; + itemType: string; + createdAt: string; +} + +export interface HistoryDraftLogGroupProps { + displayName: string; + entries: LibraryHistoryEntry[]; +} + +export interface HistoryLogGroupEntriesProps { + entries: LibraryHistoryEntry[]; +} + +export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup { + itemId: string; +} + +interface ContributorAvatarProps { + username?: string; + src: string; + className: string; + size: ComponentProps['size']; +} + +interface ContributorsAvatarsProps { + contributors: LibraryPublishContributor[]; +} + +const ContributorAvatar = ({ + username, + src, + className, + size, +}: ContributorAvatarProps) => { + const intl = useIntl(); + const [imgError, setImgError] = useState(false); + return ( + setImgError(true)} + /> + ); +}; + +const HistoryLogGroupTitle = ({ + titleMessage, + dateMessage, + disableCollapsible = false, +}: HistoryLogGroupTitleProps) => { + return ( + + + + + {titleMessage} + + + {dateMessage} + + + {!disableCollapsible && ( + <> + + + + + + + + )} + + ); +}; + +const HistoryLogGroupEntries = ({ + entries, +}: HistoryLogGroupEntriesProps) => { + const intl = useIntl(); + + const getEntryMessage = (entry: LibraryHistoryEntry) => { + switch (entry.action) { + case 'edited': + return messages.historyEditEntry; + case 'renamed': + return messages.historyRenameEntry; + case 'created': + return messages.historyCreatedEntry; + default: + return messages.historyEditEntry; + } + }; + + return ( + +
+ {entries.map((entry) => { + const entryMessage = getEntryMessage(entry); + + return ( +
+ + + + + {entry.title}, + icon: , + }} + /> + + + {moment(entry.changedAt).fromNow()} + + + +
+
+ ); + })} + + ); +}; + +export const HistoryCreatedLogGroup = ({ + user, + displayName, + itemType, + createdAt, +}: HistoryCreatedLogGroupProps) => { + const intl = useIntl(); + + return ( +
+ {displayName}, + icon: , + })} + dateMessage={moment(createdAt).fromNow()} + disableCollapsible + /> +
+ ); +}; + +export const HistoryDraftLogGroup = ({ + displayName, + entries, +}: HistoryDraftLogGroupProps) => { + const intl = useIntl(); + + return ( +
+ + + {displayName}, + }, + )} + dateMessage={intl.formatMessage( + messages.draftTitleDate, + { + count: entries.length, + date: moment(entries?.at(-1)?.changedAt ?? '').fromNow(), + }, + )} + /> + + + + + +
+
+ ); +}; + +const ContributorsAvatars = ({ contributors }: ContributorsAvatarsProps) => { + const visible = contributors.slice(0, MAX_VISIBLE_CONTRIBUTORS); + return ( + +
+ {visible.map(({ username, profileImageUrls }) => ( + + ))} +
+ + + +
+ ); +}; + +export const HistoryPublishLogGroup = ({ + itemId, + publishLogUuid, + directPublishedEntities, + publishedBy, + publishedAt, + contributors, +}: HistoryPublishLogGroupProps) => { + const intl = useIntl(); + const [isOpenCollapsible, openCollapsible, closeCollapsible] = useToggle(false); + + const { + data: entries, + isPending, + } = useLibraryPublishHistoryEntries(itemId, publishLogUuid, isOpenCollapsible); + + const dateMessage = moment(publishedAt).fromNow(); + const hasContributors = contributors.length > 0; + + const titleMessage = directPublishedEntities.length === 1 + ? intl.formatMessage(messages.publishTitle, { + user: publishedBy || intl.formatMessage(messages.historyEntryDefaultUser), + displayName: {directPublishedEntities[0].title}, + icon: , + }) + : intl.formatMessage(messages.publishTitleMultiple, { + user: publishedBy || intl.formatMessage(messages.historyEntryDefaultUser), + icon: , + }); + + return ( +
+ {hasContributors ? + ( + + + + + + {isPending ? + ( + <> +
+
+ +
+ + ) : + } + + + ) : + ( + <> + +
+ + )} + {hasContributors && ( + +
+ {!isOpenCollapsible && } + + )} +
+ ); +}; diff --git a/src/library-authoring/generic/history-log/messages.ts b/src/library-authoring/generic/history-log/messages.ts new file mode 100644 index 0000000000..6fbdc8514c --- /dev/null +++ b/src/library-authoring/generic/history-log/messages.ts @@ -0,0 +1,61 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + draftTitle: { + id: 'course-authoring.library-authoring.history.draft.title', + defaultMessage: '{displayName} is a draft', + description: 'Title for the draft group in the history log section.', + }, + publishTitle: { + id: 'course-authoring.library-authoring.history.publish.title', + defaultMessage: '{user} published {icon} {displayName}', + description: 'Title for the publish group in the history log section.', + }, + publishTitleMultiple: { + id: 'course-authoring.library-authoring.history.publish.title-multiple', + defaultMessage: '{user} published {icon} Multiple Items', + description: 'Title for the publish group in the history log section of multiple items.', + }, + draftTitleDate: { + id: 'course-authoring.library-authoring.history.draft.date', + defaultMessage: '{count, plural, one {{count} change} other {{count} changes}} since {date}', + description: 'Title for the draft group in the history log section.', + }, + createdTitle: { + id: 'course-authoring.library-authoring.history.created.title', + defaultMessage: '{user} created {icon} {displayName}', + description: 'Title for the created group in the history log section.', + }, + historyEditEntry: { + id: 'course-authoring.library-authoring.history.edit-entry', + defaultMessage: '{user} edited {icon} {displayName}', + description: 'Edit entry of the history log.', + }, + historyRenameEntry: { + id: 'course-authoring.library-authoring.history.rename-entry', + defaultMessage: '{user} renamed {icon} {displayName}', + description: 'Rename entry of the history log.', + }, + historyCreatedEntry: { + id: 'course-authoring.library-authoring.history.created-entry', + defaultMessage: '{user} created {icon} {displayName}', + description: 'Created entry of the history log.', + }, + historyDeletedEntry: { + id: 'course-authoring.library-authoring.history.deleted-entry', + defaultMessage: '{user} deleted {icon} {displayName}', + description: 'Deleted entry of the history log.', + }, + historyEntryDefaultUser: { + id: 'course-authoring.library-authoring.history.default-user', + defaultMessage: 'Author', + description: 'Default user name when the user is not available', + }, + historyContributors: { + id: 'course-authoring.library-authoring.history.contributors', + defaultMessage: '{count} {count, plural, one {author} other {authors}} contributed', + description: 'Contributors count in a publish history group', + }, +}); + +export default messages; diff --git a/src/library-authoring/generic/index.scss b/src/library-authoring/generic/index.scss index 58fee02e5d..ae5837535c 100644 --- a/src/library-authoring/generic/index.scss +++ b/src/library-authoring/generic/index.scss @@ -2,3 +2,4 @@ @import "./status-widget/StatusWidget"; @import "./parent-breadcrumbs"; @import "./publish-status-buttons"; +@import "./history-log/HistoryLog"; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index fdc71f6ef8..1211816d86 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -13,7 +13,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, type RenderResult } from '@testing-library/react'; +import { render, screen, type RenderResult } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { generatePath, @@ -244,6 +244,14 @@ const getInnerText = (element: Element | null): string => { .join(' '); }; +/** + * Returns a matcher for `getByText`/`findByText` that checks exact text match and element type. + * - Requires specifying the element's nodeName (e.g. 'P', 'DIV'). + * - Uses exact string comparison (no regex). + * + * For partial/regex matching when text is split across child elements, + * use `findByDeepTextContent` instead. + */ export const matchInnerText = ( nodeName: string, textToMatch: string, @@ -252,3 +260,23 @@ export const matchInnerText = ( !!element && element.nodeName === nodeName && getInnerText(element) === textToMatch; + +/** + * Finds the innermost element whose full textContent (normalized whitespace) matches a regex. + * Unlike `matchInnerText`, this: + * - Accepts a `RegExp` for partial/pattern matching. + * - Does NOT require specifying the element type. + * - Normalizes whitespace (collapses multiple spaces/newlines into one). + * - Returns the deepest matching element (excludes elements where a direct child also matches). + * + * Useful when text is split across child elements (e.g. by an icon or inline tag). + */ +export const findByDeepTextContent = (pattern: RegExp) => + screen.findByText((_, el) => { + if (!el) { return false; } + const normalizedText = (el.textContent ?? '').replace(/\s+/g, ' ').trim(); + if (!pattern.test(normalizedText)) { return false; } + return !Array.from(el.children).some( + (child) => pattern.test(((child as Element).textContent ?? '').replace(/\s+/g, ' ').trim()), + ); + });