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()),
+ );
+ });