From aaee15c8b172269763bf6198f0e92e22b4ed1c0c Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 17 Mar 2026 17:24:20 -0500 Subject: [PATCH 01/13] feat: History log for Components --- .../component-info/ComponentDetails.test.tsx | 14 +- .../component-info/ComponentDetails.tsx | 11 +- src/library-authoring/data/api.mocks.ts | 138 +++++++++ src/library-authoring/data/api.ts | 81 +++++ src/library-authoring/data/apiHooks.ts | 77 ++++- .../generic/history-log/HistoryLog.scss | 56 ++++ .../generic/history-log/HistoryLog.test.tsx | 81 +++++ .../generic/history-log/HistoryLog.tsx | 67 ++++ .../generic/history-log/HistoryLogGroup.tsx | 291 ++++++++++++++++++ .../generic/history-log/messages.ts | 46 +++ src/library-authoring/generic/index.scss | 1 + src/testUtils.tsx | 37 ++- 12 files changed, 878 insertions(+), 22 deletions(-) create mode 100644 src/library-authoring/generic/history-log/HistoryLog.scss create mode 100644 src/library-authoring/generic/history-log/HistoryLog.test.tsx create mode 100644 src/library-authoring/generic/history-log/HistoryLog.tsx create mode 100644 src/library-authoring/generic/history-log/HistoryLogGroup.tsx create mode 100644 src/library-authoring/generic/history-log/messages.ts diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index 127e3bc2ff..fa6fe355cc 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -10,7 +10,10 @@ import { mockFetchIndexDocuments, mockContentSearchConfig } from '@src/search-ma import { mockContentLibrary, mockGetEntityLinks, + mockLibraryBlockDraftHistory, mockLibraryBlockMetadata, + mockLibraryBlockPublishHistory, + mockLibraryBlockPublishHistoryEntries, mockXBlockAssets, mockXBlockOLX, } from '../data/api.mocks'; @@ -21,6 +24,9 @@ import ComponentDetails from './ComponentDetails'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockLibraryBlockDraftHistory.applyMock(); +mockLibraryBlockPublishHistory.applyMock(); +mockLibraryBlockPublishHistoryEntries.applyMock(); mockXBlockAssets.applyMock(); mockXBlockOLX.applyMock(); mockGetEntityLinks.applyMock(); @@ -96,11 +102,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(); + // Show created group (no draft or publish history for this usage key) + expect(await screen.findByText(/Author created this component/i)).toBeInTheDocument(); }); }); diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index 965e1235e5..287dd26e1e 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -1,14 +1,14 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Stack } from '@openedx/paragon'; -import AlertError from '../../generic/alert-error'; -import Loading from '../../generic/Loading'; +import AlertError from '@src/generic/alert-error'; +import Loading from '@src/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(); @@ -48,7 +48,10 @@ const ComponentDetails = () => {

- + diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 8842457a0f..99f275ce2e 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,136 @@ 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 = [ + { + changedBy: mockContributor('test_user_1'), + changedAt: '2026-03-16T11:00:00Z', + title: 'Electron Arcs', + action: 'edited', + blockType: 'html', + }, + { + changedBy: mockContributor('test_user_2'), + changedAt: '2026-03-13T10:00:00Z', + title: 'More on Quarks', + action: 'renamed', + blockType: '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', + title: 'Protons', + blockType: 'html', + 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, + }, +] satisfies api.LibraryPublishHistoryGroup[]; +mockLibraryBlockPublishHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory); + +/** + * Mock for `getLibraryBlockPublishHistoryEntries()` + * + * Use `mockLibraryBlockPublishHistoryEntries.applyMock()` to apply it to the whole test suite. + */ +export async function mockLibraryBlockPublishHistoryEntries( + _usageKey: string, + _publishGroupId: string, +): Promise { + return mockLibraryBlockPublishHistoryEntries.data; +} +mockLibraryBlockPublishHistoryEntries.data = [ + { + changedBy: mockContributor('test_user'), + changedAt: '2026-03-10T09:00:00Z', + title: 'Protons', + action: 'edited', + blockType: 'html', + }, +] satisfies api.LibraryHistoryEntry[]; +mockLibraryBlockPublishHistoryEntries.applyMock = () => jest.spyOn( + api, + 'getLibraryBlockPublishHistoryEntries', +).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 = { + changedBy: mockContributor('author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing 1', + blockType: 'html', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryBlockCreationEntry.dataEmpty = { + changedBy: mockContributor('Author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing 2', + blockType: 'html', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryBlockCreationEntry.applyMock = () => jest.spyOn(api, 'getLibraryBlockCreationEntry').mockImplementation(mockLibraryBlockCreationEntry); + export const mockGetMigrationInfo = { applyMock: () => jest.spyOn(api, 'getMigrationInfo').mockResolvedValue( diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 72c880558e..c2626cb30c 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -55,6 +55,26 @@ 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 entries of a publish group. + */ +export const getLibraryBlockPublishHistoryEntriesUrl = (usageKey: string, publishGroupId: string) => `${getLibraryBlockMetadataUrl(usageKey)}publish_history/${publishGroupId}/entries/` + +/** + * 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. */ @@ -332,6 +352,7 @@ export interface LibraryBlockMetadata { lastDraftCreatedBy: string | null; hasUnpublishedChanges: boolean; created: string | null; + createdBy: string | null; modified: string | null; tagsCount: number; collections: CollectionMetadata[]; @@ -932,3 +953,63 @@ export async function getModulestoreMigrationBlocksInfo( const { data } = await client.get(getModulestoreMigratedBlocksInfoUrl(), { params }); return camelCaseObject(data); } + +export interface LibraryPublishHistoryGroup { + publishLogUuid: string; + title: string; + publishedBy: string; + publishedAt: string; + blockType: string; + contributors: LibraryPublishContributor[]; + contributorsCount: number; +} + +export interface LibraryPublishContributor { + profileImageUrls: { + full: string; + large: string; + medium: string; + small: string; + }; + username: string; +} + +export interface LibraryHistoryEntry { + changedBy: LibraryPublishContributor; + changedAt: string; + title: string; + blockType: 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 getLibraryBlockPublishHistoryEntries(usageKey: string, publishGroupId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockPublishHistoryEntriesUrl(usageKey, 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); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 6a8d97c5cd..693be8eba0 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -125,6 +125,10 @@ 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 +136,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 +167,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 +299,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 +313,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 +983,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 +1035,50 @@ 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 block. + */ +export const useLibraryBlockPublishHistoryEntries = ( + usageKey?: string, + publishGroupId?: string, + enabled: boolean = true, +) => ( + useQuery({ + queryKey: xblockQueryKeys.publishHistoryEntries(usageKey!, publishGroupId!), + queryFn: (usageKey && publishGroupId && enabled) ? () => api.getLibraryBlockPublishHistoryEntries(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, + }) +); + /** * 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..a5296dca7d --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -0,0 +1,81 @@ +import { userEvent } from '@testing-library/user-event'; +import { + initializeMocks, + render, + screen, + waitFor, + findByDeepTextContent, +} from '@src/testUtils'; + +import { + mockLibraryBlockDraftHistory, + mockLibraryBlockPublishHistory, + mockLibraryBlockPublishHistoryEntries, + mockLibraryBlockCreationEntry, +} from '@src/library-authoring/data/api.mocks'; +import { HistoryComponentLog } from './HistoryLog'; + +mockLibraryBlockDraftHistory.applyMock(); +mockLibraryBlockPublishHistory.applyMock(); +mockLibraryBlockPublishHistoryEntries.applyMock(); +mockLibraryBlockCreationEntry.applyMock(); + +const renderComponent = (componentId: string) => render( + , +); + + +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(/Test Component 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('Test Component is a draft')).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(); + }); +}); \ No newline at end of file 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..072d3afef2 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -0,0 +1,67 @@ +import { LoadingSpinner } from "@src/generic/Loading"; +import { + useLibraryBlockCreationEntry, + useLibraryBlockDraftHistory, + useLibraryBlockPublishHistory, +} from "@src/library-authoring/data/apiHooks"; +import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } from "./HistoryLogGroup"; + +export interface HistoryComponentLogProps { + componentId: string; + displayName: string; +} + +export const HistoryComponentLog = ({ + componentId, + displayName, +}: HistoryComponentLogProps) => { + const { + data: draftHistory, + isPending: isPendingDraftHistory, + } = useLibraryBlockDraftHistory(componentId); + + const { + data: publishHistoryGroups, + isPending: isPendingPublishHistoryGroups, + } = useLibraryBlockPublishHistory(componentId); + + const { + data: creationEntry, + isPending: isPendingCreationEntry, + } = useLibraryBlockCreationEntry(componentId); + + if (isPendingDraftHistory || isPendingPublishHistoryGroups || isPendingCreationEntry) { + return + } + + return ( +
+ {draftHistory && draftHistory.length !== 0 && ( + + )} + {publishHistoryGroups && publishHistoryGroups.length !== 0 && ( + publishHistoryGroups.map((publishGroup) => { + return ( +
+ +
+ ) + }) + )} + {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..f714b5c2a9 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -0,0 +1,291 @@ +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 { useLibraryBlockPublishHistoryEntries } 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 [imgError, setImgError] = useState(false); + return ( + setImgError(true)} + /> + ); +}; + +const HistoryLogGroupTitle = ({ + titleMessage, + dateMessage, + disableCollapsible = false, +}: HistoryLogGroupTitleProps) => { + return ( + + + + + {titleMessage} + + + {dateMessage} + + + {!disableCollapsible && ( + <> + + + + + + + + )} + + ) +}; + +const HistoryLogGroupEntries = ({ + entries, +}: HistoryLogGroupEntriesProps) => ( + +
+ {entries.map((entry) => { + const entryMessage = entry.action === 'edited' + ? messages.historyEditEntry + : messages.historyRenameEntry; + + 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, + title, + publishedBy, + publishedAt, + blockType, + contributors, +}: HistoryPublishLogGroupProps) => { + const intl = useIntl(); + const [isOpenCollapsible, openCollapsible, closeCollapsible] = useToggle(false); + + const { + data: entries, + isPending, + } = useLibraryBlockPublishHistoryEntries(itemId, publishLogUuid, isOpenCollapsible); + + return ( +
+ + + {title}, + icon: + }, + )} + dateMessage={moment(publishedAt).fromNow()} + /> + + + {isPending ? ( + <> +
+
+ +
+ + ): ( + + )} + + + +
+ {!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..579b887936 --- /dev/null +++ b/src/library-authoring/generic/history-log/messages.ts @@ -0,0 +1,46 @@ +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.', + }, + 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.', + }, + 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..92e7c568c8 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,11 +244,36 @@ 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, -) => -(_: string, element: Element | null) => - !!element - && element.nodeName === nodeName - && getInnerText(element) === textToMatch; +) => (_: string, element: Element | null) => !!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()), + ); +}); From 0ad4c1a669a76d53ad8f248eeff74bab1e171712 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sun, 19 Apr 2026 21:47:54 -0500 Subject: [PATCH 02/13] feat: Added HistoryContainerLog && refactor to use new Pre and Post Verawood logic --- .../common/context/SidebarContext.tsx | 1 + .../component-info/ComponentDetails.tsx | 19 -- .../containers/ContainerDetails.tsx | 23 +++ .../containers/ContainerInfo.tsx | 6 + src/library-authoring/containers/messages.ts | 10 + src/library-authoring/data/api.mocks.ts | 186 +++++++++++++++--- src/library-authoring/data/api.ts | 90 +++++++-- src/library-authoring/data/apiHooks.ts | 57 +++++- .../generic/history-log/HistoryLog.test.tsx | 73 ++++++- .../generic/history-log/HistoryLog.tsx | 113 ++++++++--- .../generic/history-log/HistoryLogGroup.tsx | 126 ++++++------ .../generic/history-log/messages.ts | 5 + src/testUtils.tsx | 25 +-- 13 files changed, 569 insertions(+), 165 deletions(-) create mode 100644 src/library-authoring/containers/ContainerDetails.tsx 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.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index 287dd26e1e..11af3d13f0 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -1,10 +1,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Stack } from '@openedx/paragon'; -import AlertError from '@src/generic/alert-error'; -import Loading from '@src/generic/Loading'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useLibraryBlockMetadata } from '../data/apiHooks'; import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; import { ComponentUsage } from './ComponentUsage'; import messages from './messages'; @@ -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 ( <> @@ -50,7 +32,6 @@ 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 99f275ce2e..f0bbc159b0 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1245,9 +1245,12 @@ mockGetCourseImports.applyMock = () => 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}"`); + 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'; @@ -1268,17 +1271,18 @@ mockLibraryBlockDraftHistory.data = [ changedAt: '2026-03-16T11:00:00Z', title: 'Electron Arcs', action: 'edited', - blockType: 'html', + itemType: 'html', }, { changedBy: mockContributor('test_user_2'), changedAt: '2026-03-13T10:00:00Z', title: 'More on Quarks', action: 'renamed', - blockType: 'html', + itemType: 'html', }, ] satisfies api.LibraryHistoryEntry[]; -mockLibraryBlockDraftHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockDraftHistory').mockImplementation(mockLibraryBlockDraftHistory); +mockLibraryBlockDraftHistory.applyMock = () => + jest.spyOn(api, 'getLibraryBlockDraftHistory').mockImplementation(mockLibraryBlockDraftHistory); /** * Mock for `getLibraryBlockPublishHistory()` @@ -1288,9 +1292,12 @@ mockLibraryBlockDraftHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockD 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}"`); + 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'; @@ -1298,24 +1305,26 @@ mockLibraryBlockPublishHistory.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce mockLibraryBlockPublishHistory.data = [ { publishLogUuid: 'abc-123', - title: 'Protons', - blockType: 'html', + 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, }, ] satisfies api.LibraryPublishHistoryGroup[]; -mockLibraryBlockPublishHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory); +mockLibraryBlockPublishHistory.applyMock = () => + jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory); /** - * Mock for `getLibraryBlockPublishHistoryEntries()` + * Mock for `getLibraryPublishHistoryEntries()` * * Use `mockLibraryBlockPublishHistoryEntries.applyMock()` to apply it to the whole test suite. */ export async function mockLibraryBlockPublishHistoryEntries( - _usageKey: string, - _publishGroupId: string, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + ..._: Parameters ): Promise { return mockLibraryBlockPublishHistoryEntries.data; } @@ -1325,13 +1334,14 @@ mockLibraryBlockPublishHistoryEntries.data = [ changedAt: '2026-03-10T09:00:00Z', title: 'Protons', action: 'edited', - blockType: 'html', + itemType: 'html', }, ] satisfies api.LibraryHistoryEntry[]; -mockLibraryBlockPublishHistoryEntries.applyMock = () => jest.spyOn( - api, - 'getLibraryBlockPublishHistoryEntries', -).mockImplementation(mockLibraryBlockPublishHistoryEntries); +mockLibraryBlockPublishHistoryEntries.applyMock = () => + jest.spyOn( + api, + 'getLibraryPublishHistoryEntries', + ).mockImplementation(mockLibraryBlockPublishHistoryEntries); /** * Mock for `getLibraryBlockCreationEntry()` @@ -1343,9 +1353,12 @@ export async function mockLibraryBlockCreationEntry(usageKey: string): 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}"`); + 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'; @@ -1355,17 +1368,138 @@ mockLibraryBlockCreationEntry.data = { changedBy: mockContributor('author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing 1', - blockType: 'html', + itemType: 'html', action: 'created', } satisfies api.LibraryHistoryEntry; mockLibraryBlockCreationEntry.dataEmpty = { changedBy: mockContributor('Author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing 2', - blockType: 'html', + 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 = [ + { + changedBy: mockContributor('container_user_1'), + changedAt: '2026-03-16T11:00:00Z', + title: 'Intro Unit', + action: 'edited', + itemType: 'unit', + }, + { + changedBy: 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 = { + changedBy: mockContributor('author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing Unit 1', + itemType: 'unit', + action: 'created', +} satisfies api.LibraryHistoryEntry; +mockLibraryContainerCreationEntry.dataEmpty = { + changedBy: mockContributor('Author'), + changedAt: '2024-01-01T00:00:00Z', + title: 'Introduction to Testing Unit 2', + itemType: 'unit', action: 'created', } satisfies api.LibraryHistoryEntry; -mockLibraryBlockCreationEntry.applyMock = () => jest.spyOn(api, 'getLibraryBlockCreationEntry').mockImplementation(mockLibraryBlockCreationEntry); +mockLibraryContainerCreationEntry.applyMock = () => + jest.spyOn(api, 'getLibraryContainerCreationEntry').mockImplementation(mockLibraryContainerCreationEntry); export const mockGetMigrationInfo = { applyMock: () => diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index c2626cb30c..0393681159 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. */ @@ -58,22 +68,20 @@ export const getLibraryBlockHierarchyUrl = (usageKey: string) => `${getLibraryBl /** * Get the URL for the component draft history. */ -export const getLibraryBlockDraftHistoryUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}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 entries of a publish group. - */ -export const getLibraryBlockPublishHistoryEntriesUrl = (usageKey: string, publishGroupId: string) => `${getLibraryBlockMetadataUrl(usageKey)}publish_history/${publishGroupId}/entries/` +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/`; +export const getLibraryBlockCreationEntryUrl = (usageKey: string) => + `${getLibraryBlockMetadataUrl(usageKey)}creation_entry/`; /** * Get the URL for content library list API. @@ -180,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. */ @@ -954,14 +977,25 @@ export async function getModulestoreMigrationBlocksInfo( return camelCaseObject(data); } +export interface DirectPublishedEntity { + entityKey: string; + entityType: string; + title: string; +} export interface LibraryPublishHistoryGroup { publishLogUuid: string; - title: string; + directPublishedEntities: DirectPublishedEntity[]; publishedBy: string; publishedAt: string; - blockType: 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 { @@ -978,7 +1012,7 @@ export interface LibraryHistoryEntry { changedBy: LibraryPublishContributor; changedAt: string; title: string; - blockType: string; + itemType: string; action: 'edited' | 'renamed' | 'created'; } @@ -993,8 +1027,14 @@ export async function getLibraryBlockPublishHistory(usageKey: string): Promise { - const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockPublishHistoryEntriesUrl(usageKey, publishGroupId)); +export async function getLibraryPublishHistoryEntries( + libraryId: string, + entityKey: string, + publishGroupId: string, +): Promise { + const { data } = await getAuthenticatedHttpClient().get( + getLibraryPublishHistoryEntriesUrl(libraryId, entityKey, publishGroupId), + ); return camelCaseObject(data); } @@ -1013,3 +1053,27 @@ export async function getLibraryBlockCreationEntry(usageKey: 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 693be8eba0..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', @@ -127,7 +139,10 @@ export const xblockQueryKeys = { 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'], + publishHistoryEntries: ( + usageKey: string, + publishGroupId: string, + ) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory', publishGroupId, 'entries'], creationEntry: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'creationEntry'], /** @@ -1056,29 +1071,61 @@ export const useLibraryBlockPublishHistory = (usageKey?: string) => ( ); /** - * Returns the entries for a publish history group of a library block. + * Returns the entries for a publish history group of a library item. */ -export const useLibraryBlockPublishHistoryEntries = ( +export const useLibraryPublishHistoryEntries = ( usageKey?: string, publishGroupId?: string, enabled: boolean = true, ) => ( useQuery({ queryKey: xblockQueryKeys.publishHistoryEntries(usageKey!, publishGroupId!), - queryFn: (usageKey && publishGroupId && enabled) ? () => api.getLibraryBlockPublishHistoryEntries(usageKey, publishGroupId) : skipToken, + queryFn: (usageKey && publishGroupId && enabled) + ? () => api.getLibraryPublishHistoryEntries(getLibraryId(usageKey), usageKey, publishGroupId) + : skipToken, }) ); /** * Returns the creation entry for a library block. */ -export const useLibraryBlockCreationEntry = (usageKey?: string ) => ( +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.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index a5296dca7d..36e7d4f7c2 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -12,18 +12,33 @@ import { mockLibraryBlockPublishHistory, mockLibraryBlockPublishHistoryEntries, mockLibraryBlockCreationEntry, + mockLibraryBlockMetadata, + mockLibraryContainerDraftHistory, + mockLibraryContainerPublishHistory, + mockLibraryContainerCreationEntry, + mockGetContainerMetadata, } from '@src/library-authoring/data/api.mocks'; -import { HistoryComponentLog } from './HistoryLog'; +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 renderComponent = (componentId: string) => + render( + , + ); +const renderContainerComponent = (containerId: string) => + render( + , + ); describe('', () => { beforeEach(() => { @@ -38,7 +53,7 @@ describe('', () => { it('renders draft history group with entries when they exist', async () => { const user = userEvent.setup(); renderComponent(mockLibraryBlockCreationEntry.usageKey); - const trigger = await findByDeepTextContent(/Test Component is a draft/i); + 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(); @@ -48,7 +63,7 @@ describe('', () => { 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('Test Component is a draft')).not.toBeInTheDocument(); + expect(screen.queryByText(/is a draft/i)).not.toBeInTheDocument(); }); it('renders publish history group when one exists', async () => { @@ -78,4 +93,48 @@ describe('', () => { renderComponent(mockLibraryBlockCreationEntry.usageKey); expect(await findByDeepTextContent(/Author created.*Introduction to Testing 1/i)).toBeInTheDocument(); }); -}); \ No newline at end of file +}); + +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('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 index 072d3afef2..c78e5cdc97 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -1,20 +1,17 @@ -import { LoadingSpinner } from "@src/generic/Loading"; +import { LoadingSpinner } from '@src/generic/Loading'; import { + useContainer, useLibraryBlockCreationEntry, useLibraryBlockDraftHistory, + useLibraryBlockMetadata, useLibraryBlockPublishHistory, -} from "@src/library-authoring/data/apiHooks"; -import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } from "./HistoryLogGroup"; + useLibraryContainerCreationEntry, + useLibraryContainerDraftHistory, + useLibraryContainerPublishHistory, +} from '@src/library-authoring/data/apiHooks'; +import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } from './HistoryLogGroup'; -export interface HistoryComponentLogProps { - componentId: string; - displayName: string; -} - -export const HistoryComponentLog = ({ - componentId, - displayName, -}: HistoryComponentLogProps) => { +export const HistoryComponentLog = ({ componentId }: { componentId: string; }) => { const { data: draftHistory, isPending: isPendingDraftHistory, @@ -28,37 +25,95 @@ export const HistoryComponentLog = ({ const { data: creationEntry, isPending: isPendingCreationEntry, - } = useLibraryBlockCreationEntry(componentId); + } = useLibraryBlockCreationEntry(componentId); + + const { + data: componentMetadata, + isPending: isPendingComponentMetadata, + } = useLibraryBlockMetadata(componentId); - if (isPendingDraftHistory || isPendingPublishHistoryGroups || isPendingCreationEntry) { - return + 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) => { - return ( -
    - -
    - ) - }) + publishHistoryGroups.map((publishGroup) => ( +
    + +
    + )) )} {creationEntry && ( )} diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index f714b5c2a9..f48490b385 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -1,16 +1,16 @@ -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 { 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 { useLibraryBlockPublishHistoryEntries } from "@src/library-authoring/data/apiHooks"; -import { LoadingSpinner } from "@src/generic/Loading"; +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"; +import { LibraryHistoryEntry, LibraryPublishContributor, LibraryPublishHistoryGroup } from '../../data/api'; +import messages from './messages'; +import { getItemIcon } from '@src/generic/block-type-utils'; const MAX_VISIBLE_CONTRIBUTORS = 5; @@ -76,7 +76,7 @@ const HistoryLogGroupTitle = ({ }: HistoryLogGroupTitleProps) => { return ( - + {titleMessage} @@ -88,15 +88,15 @@ const HistoryLogGroupTitle = ({ {!disableCollapsible && ( <> - + - + )} - ) + ); }; const HistoryLogGroupEntries = ({ @@ -108,7 +108,7 @@ const HistoryLogGroupEntries = ({ const entryMessage = entry.action === 'edited' ? messages.historyEditEntry : messages.historyRenameEntry; - + return (
    @@ -125,7 +125,7 @@ const HistoryLogGroupEntries = ({ values={{ user: entry.changedBy.username, displayName: {entry.title}, - icon: + icon: , }} /> @@ -133,7 +133,6 @@ const HistoryLogGroupEntries = ({ {moment(entry.changedAt).fromNow()} -
    @@ -179,15 +178,15 @@ export const HistoryDraftLogGroup = ({ titleMessage={intl.formatMessage( messages.draftTitle, { - displayName: {displayName} - } + displayName: {displayName}, + }, )} dateMessage={intl.formatMessage( messages.draftTitleDate, { count: entries.length, date: moment(entries?.at(-1)?.changedAt ?? '').fromNow(), - } + }, )} /> @@ -208,7 +207,7 @@ const ContributorsAvatars = ({ contributors }: ContributorsAvatarsProps) => { {visible.map(({ username, profileImageUrls }) => ( { export const HistoryPublishLogGroup = ({ itemId, publishLogUuid, - title, + directPublishedEntities, publishedBy, publishedAt, - blockType, contributors, }: HistoryPublishLogGroupProps) => { const intl = useIntl(); @@ -240,7 +238,9 @@ export const HistoryPublishLogGroup = ({ const { data: entries, isPending, - } = useLibraryBlockPublishHistoryEntries(itemId, publishLogUuid, isOpenCollapsible); + } = useLibraryPublishHistoryEntries(itemId, publishLogUuid, isOpenCollapsible); + + const dateMessage = moment(publishedAt).fromNow(); return (
    @@ -250,41 +250,57 @@ export const HistoryPublishLogGroup = ({ onClose={closeCollapsible} > - {title}, - icon: - }, - )} - dateMessage={moment(publishedAt).fromNow()} - /> + {directPublishedEntities.length === 1 && ( + {directPublishedEntities[0].title} + ), + icon: , + }, + )} + dateMessage={dateMessage} + /> + )} + {directPublishedEntities.length > 1 && ( + , + }, + )} + dateMessage={dateMessage} + /> + )} - {isPending ? ( - <> -
    -
    - -
    - - ): ( - - )} + {isPending ? + ( + <> +
    +
    + +
    + + ) : + } -
    - {!isOpenCollapsible && ( - - )} +
    + {!isOpenCollapsible && }
    ); diff --git a/src/library-authoring/generic/history-log/messages.ts b/src/library-authoring/generic/history-log/messages.ts index 579b887936..a8fa37d8e9 100644 --- a/src/library-authoring/generic/history-log/messages.ts +++ b/src/library-authoring/generic/history-log/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ 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}', diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 92e7c568c8..1211816d86 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -255,9 +255,11 @@ const getInnerText = (element: Element | null): string => { export const matchInnerText = ( nodeName: string, textToMatch: string, -) => (_: string, element: Element | null) => !!element - && element.nodeName === nodeName - && getInnerText(element) === textToMatch; +) => +(_: string, element: Element | null) => + !!element + && element.nodeName === nodeName + && getInnerText(element) === textToMatch; /** * Finds the innermost element whose full textContent (normalized whitespace) matches a regex. @@ -269,11 +271,12 @@ export const matchInnerText = ( * * 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()), - ); -}); +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()), + ); + }); From d3fcc4de9e288c025652620a8e66f06296b691a9 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 18:24:52 -0500 Subject: [PATCH 03/13] fix: Undefined changeBy in History log --- src/library-authoring/data/api.mocks.ts | 2 +- src/library-authoring/data/api.ts | 4 +- .../generic/history-log/HistoryLog.test.tsx | 42 ++++++++++ .../generic/history-log/HistoryLog.tsx | 4 +- .../generic/history-log/HistoryLogGroup.tsx | 83 ++++++++++--------- 5 files changed, 91 insertions(+), 44 deletions(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index f0bbc159b0..0720c723a6 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1313,7 +1313,7 @@ mockLibraryBlockPublishHistory.data = [ contributors: ['test_user_1', 'test_user_2', 'test_user_3', 'test_user_4', 'test_user_5'].map(mockContributor), contributorsCount: 5, }, -] satisfies api.LibraryPublishHistoryGroup[]; +] as api.LibraryPublishHistoryGroup[]; mockLibraryBlockPublishHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 0393681159..1393c56a58 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -985,7 +985,7 @@ export interface DirectPublishedEntity { export interface LibraryPublishHistoryGroup { publishLogUuid: string; directPublishedEntities: DirectPublishedEntity[]; - publishedBy: string; + publishedBy?: string; publishedAt: string; contributors: LibraryPublishContributor[]; contributorsCount: number; @@ -1005,7 +1005,7 @@ export interface LibraryPublishContributor { medium: string; small: string; }; - username: string; + username?: string; } export interface LibraryHistoryEntry { diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index 36e7d4f7c2..13c7deb18b 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -7,6 +7,7 @@ import { findByDeepTextContent, } from '@src/testUtils'; +import type { LibraryPublishContributor } from '@src/library-authoring/data/api'; import { mockLibraryBlockDraftHistory, mockLibraryBlockPublishHistory, @@ -40,6 +41,15 @@ const renderContainerComponent = (containerId: string) => , ); +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(); @@ -93,6 +103,38 @@ describe('', () => { renderComponent(mockLibraryBlockCreationEntry.usageKey); expect(await findByDeepTextContent(/Author created.*Introduction to Testing 1/i)).toBeInTheDocument(); }); + + it('shows fallback "Author" for draft entry when changedBy has no username', async () => { + const user = userEvent.setup(); + const originalData = mockLibraryBlockDraftHistory.data; + mockLibraryBlockDraftHistory.data = [ + { + changedBy: 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; + }); }); describe('', () => { diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx index c78e5cdc97..16cd5f5423 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -56,7 +56,7 @@ export const HistoryComponentLog = ({ componentId }: { componentId: string; }) = )} {creationEntry && ( ['size']; @@ -57,13 +57,14 @@ const ContributorAvatar = ({ className, size, }: ContributorAvatarProps) => { + const intl = useIntl(); const [imgError, setImgError] = useState(false); return ( setImgError(true)} /> ); @@ -101,45 +102,49 @@ const HistoryLogGroupTitle = ({ const HistoryLogGroupEntries = ({ entries, -}: HistoryLogGroupEntriesProps) => ( - -
    - {entries.map((entry) => { - const entryMessage = entry.action === 'edited' - ? messages.historyEditEntry - : messages.historyRenameEntry; +}: HistoryLogGroupEntriesProps) => { + const intl = useIntl(); - return ( -
    - - - - - {entry.title}, - icon: , - }} - /> + return ( + +
    + {entries.map((entry) => { + const entryMessage = entry.action === 'edited' + ? messages.historyEditEntry + : messages.historyRenameEntry; + + return ( +
    + + + + + {entry.title}, + icon: , + }} + /> + + + {moment(entry.changedAt).fromNow()} + - - {moment(entry.changedAt).fromNow()} - - -
    -
    - ); - })} - -); +
    +
    + ); + })} + + ); +}; export const HistoryCreatedLogGroup = ({ user, @@ -255,7 +260,7 @@ export const HistoryPublishLogGroup = ({ titleMessage={intl.formatMessage( messages.publishTitle, { - user: publishedBy, + user: publishedBy || intl.formatMessage(messages.historyEntryDefaultUser), displayName: ( {directPublishedEntities[0].title} ), From 204ff861c68d145d2338d20a0a78bd9a061fbda8 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 18:52:05 -0500 Subject: [PATCH 04/13] fix: Broken tests --- .../component-info/ComponentDetails.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index fa6fe355cc..7aed5b9da8 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -4,12 +4,14 @@ 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, @@ -24,6 +26,7 @@ import ComponentDetails from './ComponentDetails'; mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockMetadata.applyMock(); +mockLibraryBlockCreationEntry.applyMock(); mockLibraryBlockDraftHistory.applyMock(); mockLibraryBlockPublishHistory.applyMock(); mockLibraryBlockPublishHistoryEntries.applyMock(); @@ -66,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 () => { @@ -102,7 +107,7 @@ describe('', () => { it('should render the component history', async () => { render(mockLibraryBlockMetadata.usageKeyPublished); - // Show created group (no draft or publish history for this usage key) - expect(await screen.findByText(/Author created this component/i)).toBeInTheDocument(); + // usageKeyPublished matches usageKeyEmpty in mockLibraryBlockCreationEntry (TEST2 key) + expect(await findByDeepTextContent(/Author created.*Introduction to Testing 2/i)).toBeInTheDocument(); }); }); From 8724d5155fb6b22446bef7b42bf49988b4266418 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 20:05:49 -0500 Subject: [PATCH 05/13] test: add tests to fix coverage --- src/library-authoring/data/api.test.ts | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) 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); + }); + }); }); From ece3b9e7795efcc9ad8b892c188113daeefee6fe Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 20 Apr 2026 21:11:55 -0500 Subject: [PATCH 06/13] fix: Broken published groups --- .../generic/history-log/HistoryLog.test.tsx | 17 +++ .../generic/history-log/HistoryLogGroup.tsx | 113 +++++++++--------- 2 files changed, 72 insertions(+), 58 deletions(-) diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index 13c7deb18b..822c7433b7 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -135,6 +135,23 @@ describe('', () => { expect(await findByDeepTextContent(/Author published.*Protons/i)).toBeInTheDocument(); mockLibraryBlockPublishHistory.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('', () => { diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 9b26d4ecd4..46439b99b0 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -246,67 +246,64 @@ export const HistoryPublishLogGroup = ({ } = 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 (
    - - - {directPublishedEntities.length === 1 && ( - {directPublishedEntities[0].title} - ), - icon: , - }, - )} - dateMessage={dateMessage} - /> - )} - {directPublishedEntities.length > 1 && ( - , - }, - )} - dateMessage={dateMessage} - /> - )} - - - {isPending ? - ( - <> -
    -
    - -
    - - ) : - } - - - -
    - {!isOpenCollapsible && } - + {hasContributors ? + ( + + + + + + {isPending ? + ( + <> +
    +
    + +
    + + ) : + } + + + ) : + ( + <> + +
    + + )} + {hasContributors && ( + +
    + {!isOpenCollapsible && } + + )}
    ); }; From 1773c33b290e3de27edf4d67f0bb390c11e0eb65 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 16:35:02 -0500 Subject: [PATCH 07/13] feat: Add created entry in history log --- .../generic/history-log/HistoryLog.test.tsx | 54 +++++++++++++++++++ .../generic/history-log/HistoryLogGroup.tsx | 17 ++++-- .../generic/history-log/messages.ts | 10 ++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index 822c7433b7..db03677804 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -136,6 +136,33 @@ describe('', () => { mockLibraryBlockPublishHistory.data = originalData; }); + it('renders draft entry with "created" action', async () => { + const user = userEvent.setup(); + const originalData = mockLibraryBlockDraftHistory.data; + mockLibraryBlockDraftHistory.data = [ + { + changedBy: { + 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 = [ @@ -192,6 +219,33 @@ describe('', () => { 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 = [ + { + changedBy: { + 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/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 46439b99b0..43469c291e 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -105,13 +105,24 @@ const HistoryLogGroupEntries = ({ }: 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 = entry.action === 'edited' - ? messages.historyEditEntry - : messages.historyRenameEntry; + const entryMessage = getEntryMessage(entry); return (
    diff --git a/src/library-authoring/generic/history-log/messages.ts b/src/library-authoring/generic/history-log/messages.ts index a8fa37d8e9..6fbdc8514c 100644 --- a/src/library-authoring/generic/history-log/messages.ts +++ b/src/library-authoring/generic/history-log/messages.ts @@ -36,6 +36,16 @@ const messages = defineMessages({ 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', From 5f84b9bf6050917ba82cd5c4e707ee8272b8efb3 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 24 Apr 2026 18:03:44 -0500 Subject: [PATCH 08/13] refactor: Use new `contributor` field --- src/library-authoring/data/api.mocks.ts | 18 +++++++++--------- src/library-authoring/data/api.ts | 2 +- .../generic/history-log/HistoryLog.test.tsx | 8 ++++---- .../generic/history-log/HistoryLog.tsx | 4 ++-- .../generic/history-log/HistoryLogGroup.tsx | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 0720c723a6..3507ec6ca7 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1267,14 +1267,14 @@ const mockContributor = (username: string): api.LibraryPublishContributor => ({ mockLibraryBlockDraftHistory.data = [ { - changedBy: mockContributor('test_user_1'), + contributor: mockContributor('test_user_1'), changedAt: '2026-03-16T11:00:00Z', title: 'Electron Arcs', action: 'edited', itemType: 'html', }, { - changedBy: mockContributor('test_user_2'), + contributor: mockContributor('test_user_2'), changedAt: '2026-03-13T10:00:00Z', title: 'More on Quarks', action: 'renamed', @@ -1330,7 +1330,7 @@ export async function mockLibraryBlockPublishHistoryEntries( } mockLibraryBlockPublishHistoryEntries.data = [ { - changedBy: mockContributor('test_user'), + contributor: mockContributor('test_user'), changedAt: '2026-03-10T09:00:00Z', title: 'Protons', action: 'edited', @@ -1365,14 +1365,14 @@ mockLibraryBlockCreationEntry.usageKeyThatNeverLoads = 'lb:Axim:infiniteLoading: mockLibraryBlockCreationEntry.usageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; mockLibraryBlockCreationEntry.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; mockLibraryBlockCreationEntry.data = { - changedBy: mockContributor('author'), + contributor: mockContributor('author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing 1', itemType: 'html', action: 'created', } satisfies api.LibraryHistoryEntry; mockLibraryBlockCreationEntry.dataEmpty = { - changedBy: mockContributor('Author'), + contributor: mockContributor('Author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing 2', itemType: 'html', @@ -1404,14 +1404,14 @@ mockLibraryContainerDraftHistory.containerKey = 'lct:Axim:TEST1:unit:571fe018-f3 mockLibraryContainerDraftHistory.containerKeyEmpty = 'lct:Axim:TEST2:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; mockLibraryContainerDraftHistory.data = [ { - changedBy: mockContributor('container_user_1'), + contributor: mockContributor('container_user_1'), changedAt: '2026-03-16T11:00:00Z', title: 'Intro Unit', action: 'edited', itemType: 'unit', }, { - changedBy: mockContributor('container_user_2'), + contributor: mockContributor('container_user_2'), changedAt: '2026-03-13T10:00:00Z', title: 'Unit Renamed', action: 'renamed', @@ -1485,14 +1485,14 @@ mockLibraryContainerCreationEntry.usageKeyThatNeverLoads = 'lct:Axim:TEST1:unit: mockLibraryContainerCreationEntry.usageKey = 'lct:Axim:TEST1:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; mockLibraryContainerCreationEntry.usageKeyEmpty = 'lct:Axim:TEST2:unit:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; mockLibraryContainerCreationEntry.data = { - changedBy: mockContributor('author'), + contributor: mockContributor('author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing Unit 1', itemType: 'unit', action: 'created', } satisfies api.LibraryHistoryEntry; mockLibraryContainerCreationEntry.dataEmpty = { - changedBy: mockContributor('Author'), + contributor: mockContributor('Author'), changedAt: '2024-01-01T00:00:00Z', title: 'Introduction to Testing Unit 2', itemType: 'unit', diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 1393c56a58..e2faf17506 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -1009,7 +1009,7 @@ export interface LibraryPublishContributor { } export interface LibraryHistoryEntry { - changedBy: LibraryPublishContributor; + contributor: LibraryPublishContributor; changedAt: string; title: string; itemType: string; diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index db03677804..dbf702c078 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -104,12 +104,12 @@ describe('', () => { expect(await findByDeepTextContent(/Author created.*Introduction to Testing 1/i)).toBeInTheDocument(); }); - it('shows fallback "Author" for draft entry when changedBy has no username', async () => { + it('shows fallback "Author" for draft entry when contributor has no username', async () => { const user = userEvent.setup(); const originalData = mockLibraryBlockDraftHistory.data; mockLibraryBlockDraftHistory.data = [ { - changedBy: mockContributorNoUsername(), + contributor: mockContributorNoUsername(), changedAt: '2026-03-16T11:00:00Z', title: 'Anonymous Component', itemType: 'html', @@ -141,7 +141,7 @@ describe('', () => { const originalData = mockLibraryBlockDraftHistory.data; mockLibraryBlockDraftHistory.data = [ { - changedBy: { + contributor: { username: 'creator_user', profileImageUrls: { full: 'icon/mock/path', @@ -224,7 +224,7 @@ describe('', () => { const originalData = mockLibraryContainerDraftHistory.data; mockLibraryContainerDraftHistory.data = [ { - changedBy: { + contributor: { username: 'creator_user', profileImageUrls: { full: 'icon/mock/path', diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx index 16cd5f5423..70baa6dd74 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -56,7 +56,7 @@ export const HistoryComponentLog = ({ componentId }: { componentId: string; }) = )} {creationEntry && ( @@ -138,7 +138,7 @@ const HistoryLogGroupEntries = ({ {entry.title}, icon: , }} From e7d449848ddee4ff9ea3086ed113a1a6c1abc476 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 28 Apr 2026 11:55:14 -0500 Subject: [PATCH 09/13] fix: Delete contributorsCount in the code --- src/library-authoring/data/api.mocks.ts | 2 -- src/library-authoring/data/api.ts | 1 - src/library-authoring/generic/history-log/HistoryLog.test.tsx | 1 - 3 files changed, 4 deletions(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 3507ec6ca7..cefaf6a00c 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1311,7 +1311,6 @@ mockLibraryBlockPublishHistory.data = [ 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 = () => @@ -1457,7 +1456,6 @@ mockLibraryContainerPublishHistory.data = [ 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 = () => diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index e2faf17506..b7f157084c 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -988,7 +988,6 @@ export interface LibraryPublishHistoryGroup { 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). diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index dbf702c078..26d059c988 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -169,7 +169,6 @@ describe('', () => { { ...originalData[0], contributors: [], - contributorsCount: 0, }, ]; renderComponent(mockLibraryBlockCreationEntry.usageKey); From b80e89f4f7ff7d4945c183c4a97473662be6d21e Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 28 Apr 2026 19:55:39 -0500 Subject: [PATCH 10/13] refactor: split container publish history groups by creation time --- .../generic/history-log/HistoryLog.test.tsx | 58 +++++++++++++++++++ .../generic/history-log/HistoryLog.tsx | 50 ++++++++++++---- .../generic/history-log/HistoryLogGroup.tsx | 45 ++++++++++---- 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index 26d059c988..97199e095a 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -249,4 +249,62 @@ describe('', () => { renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); expect(await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i)).toBeInTheDocument(); }); + + it('renders publish groups after container creation before the created entry', async () => { + // Default mock: publishedAt '2026-03-14' is after createdAt '2024-01-01' + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + const publishGroup = await findByDeepTextContent(/container_author published.*Intro Unit/i); + const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); + expect(publishGroup.compareDocumentPosition(createdEntry)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('renders publish groups before container creation after the created entry', async () => { + const originalData = mockLibraryContainerPublishHistory.data; + mockLibraryContainerPublishHistory.data = [ + { + ...originalData[0], + publishedAt: '2023-01-01T00:00:00Z', // before createdAt '2024-01-01' + }, + ]; + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); + expect(await screen.findByText(/2 authors contributed/i)).toBeInTheDocument(); + // The publish group contributors should still appear + const contributorsText = screen.getByText(/2 authors contributed/i); + expect(createdEntry.compareDocumentPosition(contributorsText)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + mockLibraryContainerPublishHistory.data = originalData; + }); + + it('renders both pre-creation and post-creation publish groups in correct order', async () => { + const originalData = mockLibraryContainerPublishHistory.data; + mockLibraryContainerPublishHistory.data = [ + { + ...originalData[0], + publishLogUuid: 'after-uuid', + publishedAt: '2026-01-01T00:00:00Z', // after createdAt '2024-01-01' + directPublishedEntities: [ + { ...originalData[0].directPublishedEntities[0], entityKey: 'key-after', title: 'After Unit' }, + ], + contributors: [], + }, + { + ...originalData[0], + publishLogUuid: 'before-uuid', + publishedAt: '2023-01-01T00:00:00Z', // before createdAt '2024-01-01' + directPublishedEntities: [ + { ...originalData[0].directPublishedEntities[0], entityKey: 'key-before', title: 'Before Unit' }, + ], + contributors: [], + }, + ]; + renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + const afterGroup = await findByDeepTextContent(/container_author published.*After Unit/i); + const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); + // After-creation group comes before the created entry + expect(afterGroup.compareDocumentPosition(createdEntry)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + // Before-creation group title is hidden (hideLogVert=true, no contributors), so only the vert line renders + // Verify the after-group is visible but before-group title is not + expect(screen.queryByText(/container_author published.*Before Unit/i)).not.toBeInTheDocument(); + mockLibraryContainerPublishHistory.data = originalData; + }); }); diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx index 70baa6dd74..9e2b192175 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -10,6 +10,8 @@ import { useLibraryContainerPublishHistory, } from '@src/library-authoring/data/apiHooks'; import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } from './HistoryLogGroup'; +import { useMemo } from 'react'; +import { LibraryPublishHistoryGroup } from '@src/library-authoring/data/api'; export const HistoryComponentLog = ({ componentId }: { componentId: string; }) => { const { @@ -87,10 +89,45 @@ export const HistoryContainerLog = ({ containerId }: { containerId: string; }) = isPending: isPendingContainer, } = useContainer(containerId); + const creationTime = creationEntry?.changedAt; + + const { + groupsAfterCreation, + groupsBeforeCreation, + } = useMemo(() => { + return { + groupsAfterCreation: publishHistoryGroups?.filter( + (group) => !creationTime || group.publishedAt >= creationTime, + ) ?? [], + groupsBeforeCreation: publishHistoryGroups?.filter( + (group) => creationTime && group.publishedAt < creationTime, + ) ?? [], + }; + }, [publishHistoryGroups, creationTime]); + if (isPendingDraftHistory || isPendingContainer || isPendingPublishHistoryGroups || isPendingCreationEntry) { return ; } + const hasBeforeCreationGroups = groupsBeforeCreation.length > 0; + + const renderPublishGroups = ( + groups: LibraryPublishHistoryGroup[], + isBeforeGroup: boolean, + ) => + groups.map((publishGroup, index, arr) => { + const isLast = index === arr.length - 1; + return ( +
    + +
    + ); + }); + return (
    {draftHistory && draftHistory.length !== 0 && ( @@ -99,24 +136,17 @@ export const HistoryContainerLog = ({ containerId }: { containerId: string; }) = entries={draftHistory} /> )} - {publishHistoryGroups && publishHistoryGroups.length !== 0 && ( - publishHistoryGroups.map((publishGroup) => ( -
    - -
    - )) - )} + {renderPublishGroups(groupsAfterCreation, false)} {creationEntry && ( )} + {hasBeforeCreationGroups && renderPublishGroups(groupsBeforeCreation, true)}
    ); }; diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 07f9d45106..a9cb62a3a3 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -25,6 +25,7 @@ export interface HistoryCreatedLogGroupProps { displayName: string; itemType: string; createdAt: string; + showLogVert?: boolean; } export interface HistoryDraftLogGroupProps { @@ -34,10 +35,12 @@ export interface HistoryDraftLogGroupProps { export interface HistoryLogGroupEntriesProps { entries: LibraryHistoryEntry[]; + hideLastLogVert?: boolean; } export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup { itemId: string; + hideLogVert?: boolean; } interface ContributorAvatarProps { @@ -102,6 +105,7 @@ const HistoryLogGroupTitle = ({ const HistoryLogGroupEntries = ({ entries, + hideLastLogVert, }: HistoryLogGroupEntriesProps) => { const intl = useIntl(); @@ -121,7 +125,8 @@ const HistoryLogGroupEntries = ({ return (
    - {entries.map((entry) => { + {entries.map((entry, index, arr) => { + const isLast = index === arr.length - 1; const entryMessage = getEntryMessage(entry); return ( @@ -149,7 +154,7 @@ const HistoryLogGroupEntries = ({ -
    + {!isLast && !hideLastLogVert &&
    }
    ); })} @@ -162,6 +167,7 @@ export const HistoryCreatedLogGroup = ({ displayName, itemType, createdAt, + showLogVert, }: HistoryCreatedLogGroupProps) => { const intl = useIntl(); @@ -176,6 +182,7 @@ export const HistoryCreatedLogGroup = ({ dateMessage={moment(createdAt).fromNow()} disableCollapsible /> + {showLogVert &&
    }
    ); }; @@ -247,6 +254,7 @@ export const HistoryPublishLogGroup = ({ publishedBy, publishedAt, contributors, + hideLogVert, }: HistoryPublishLogGroupProps) => { const intl = useIntl(); const [isOpenCollapsible, openCollapsible, closeCollapsible] = useToggle(false); @@ -298,21 +306,36 @@ export const HistoryPublishLogGroup = ({ ) : ( <> - + {!hideLogVert && ( + + )}
    )} {hasContributors && ( -
    + )} + {!isOpenCollapsible && + ( +
    + +
    )} - /> - {!isOpenCollapsible && } )}
    From 74872c3aa0f92c6969172e67fff581ec9f02533b Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 29 Apr 2026 14:57:11 -0500 Subject: [PATCH 11/13] fix: Broken null contributors --- src/library-authoring/data/api.ts | 2 +- .../generic/history-log/HistoryLogGroup.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index b7f157084c..3fd7d8af4e 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -1008,7 +1008,7 @@ export interface LibraryPublishContributor { } export interface LibraryHistoryEntry { - contributor: LibraryPublishContributor; + contributor?: LibraryPublishContributor | null; changedAt: string; title: string; itemType: string; diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index a9cb62a3a3..83fafdd787 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -45,7 +45,7 @@ export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup interface ContributorAvatarProps { username?: string; - src: string; + src?: string; className: string; size: ComponentProps['size']; } @@ -134,7 +134,7 @@ const HistoryLogGroupEntries = ({ @@ -143,7 +143,7 @@ const HistoryLogGroupEntries = ({ {entry.title}, icon: , }} From 0f85adab064b62fcb72c39a57c2e26ad4cca8a06 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 29 Apr 2026 16:26:09 -0500 Subject: [PATCH 12/13] fix: nits on the code --- .../containers/ContainerDetails.tsx | 8 +- src/library-authoring/containers/messages.ts | 3 + src/library-authoring/data/api.ts | 13 +- src/library-authoring/data/apiHooks.ts | 12 +- .../generic/history-log/HistoryLog.test.tsx | 114 ++++++++---------- .../generic/history-log/HistoryLog.tsx | 18 +++ .../generic/history-log/HistoryLogGroup.tsx | 7 +- 7 files changed, 95 insertions(+), 80 deletions(-) diff --git a/src/library-authoring/containers/ContainerDetails.tsx b/src/library-authoring/containers/ContainerDetails.tsx index 2019533cf8..a9866c26d1 100644 --- a/src/library-authoring/containers/ContainerDetails.tsx +++ b/src/library-authoring/containers/ContainerDetails.tsx @@ -1,18 +1,18 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage } 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 && ( /** * Get the URL for the entries of a publish group. + * publishGroupUuid: UUID of the PublishLog that groups this set of published entities. */ export const getLibraryPublishHistoryEntriesUrl = ( libraryId: string, entityKey: string, - publishGroupId: string, + publishGroupUuid: string, ) => - `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/publish_history_entries/?scope_entity_key=${entityKey}&publish_log_uuid=${publishGroupId}`; + `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/publish_history_entries/?scope_entity_key=${entityKey}&publish_log_uuid=${publishGroupUuid}`; /** * Get the URL for library block metadata. @@ -79,6 +80,7 @@ export const getLibraryBlockPublishHistoryUrl = (usageKey: string) => /** * Get the URL for the creation entry of a component. + * The creation entry is the draft change record representing when the component was first added to the library. */ export const getLibraryBlockCreationEntryUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}creation_entry/`; @@ -200,6 +202,7 @@ export const getLibraryContainerPublishHistoryUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}publish_history/`; /** * Get the URL for the creation entry of a container. + * The creation entry is the draft change record representing when the container was first added to the library. */ export const getLibraryContainerCreationEntryUrl = (usageKey: string) => `${getLibraryContainerApiUrl(usageKey)}creation_entry/`; @@ -990,7 +993,7 @@ export interface LibraryPublishHistoryGroup { contributors: LibraryPublishContributor[]; /** * 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). + * Pre-Verawood history entries: 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. */ @@ -1029,10 +1032,10 @@ export async function getLibraryBlockPublishHistory(usageKey: string): Promise { const { data } = await getAuthenticatedHttpClient().get( - getLibraryPublishHistoryEntriesUrl(libraryId, entityKey, publishGroupId), + getLibraryPublishHistoryEntriesUrl(libraryId, entityKey, publishGroupUuid), ); return camelCaseObject(data); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index ff12390dae..610a330941 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -141,8 +141,8 @@ export const xblockQueryKeys = { publishHistory: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory'], publishHistoryEntries: ( usageKey: string, - publishGroupId: string, - ) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory', publishGroupId, 'entries'], + publishGroupUuid: string, + ) => [...xblockQueryKeys.xblock(usageKey), 'publishHistory', publishGroupUuid, 'entries'], creationEntry: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'creationEntry'], /** @@ -1075,13 +1075,13 @@ export const useLibraryBlockPublishHistory = (usageKey?: string) => ( */ export const useLibraryPublishHistoryEntries = ( usageKey?: string, - publishGroupId?: string, + publishGroupUuid?: string, enabled: boolean = true, ) => ( useQuery({ - queryKey: xblockQueryKeys.publishHistoryEntries(usageKey!, publishGroupId!), - queryFn: (usageKey && publishGroupId && enabled) - ? () => api.getLibraryPublishHistoryEntries(getLibraryId(usageKey), usageKey, publishGroupId) + queryKey: xblockQueryKeys.publishHistoryEntries(usageKey!, publishGroupUuid!), + queryFn: (usageKey && publishGroupUuid && enabled) + ? () => api.getLibraryPublishHistoryEntries(getLibraryId(usageKey), usageKey, publishGroupUuid) : skipToken, }) ); diff --git a/src/library-authoring/generic/history-log/HistoryLog.test.tsx b/src/library-authoring/generic/history-log/HistoryLog.test.tsx index 97199e095a..7e1b6f23a6 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.test.tsx @@ -8,28 +8,18 @@ import { } 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 * as apiMocks 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(); +apiMocks.mockLibraryBlockDraftHistory.applyMock(); +apiMocks.mockLibraryBlockPublishHistory.applyMock(); +apiMocks.mockLibraryBlockPublishHistoryEntries.applyMock(); +apiMocks.mockLibraryBlockCreationEntry.applyMock(); +apiMocks.mockLibraryBlockMetadata.applyMock(); +apiMocks.mockLibraryContainerDraftHistory.applyMock(); +apiMocks.mockLibraryContainerPublishHistory.applyMock(); +apiMocks.mockLibraryContainerCreationEntry.applyMock(); +apiMocks.mockGetContainerMetadata.applyMock(); const renderComponent = (componentId: string) => render( @@ -56,13 +46,13 @@ describe('', () => { }); it('shows loading spinner while fetching', () => { - renderComponent(mockLibraryBlockCreationEntry.usageKeyThatNeverLoads); + renderComponent(apiMocks.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); + renderComponent(apiMocks.mockLibraryBlockCreationEntry.usageKey); const trigger = await findByDeepTextContent(/Introduction to Testing 1 is a draft/i); expect(trigger).toBeInTheDocument(); await user.click(trigger); @@ -71,20 +61,20 @@ describe('', () => { }); it('does not render draft history group when there are no draft entries', async () => { - renderComponent(mockLibraryBlockCreationEntry.usageKeyEmpty); + renderComponent(apiMocks.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); + renderComponent(apiMocks.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); + renderComponent(apiMocks.mockLibraryBlockCreationEntry.usageKey); expect(await screen.findByText(/5 authors contributed/i)).toBeInTheDocument(); const publishTrigger = await findByDeepTextContent(/author published.*Protons/i); @@ -94,20 +84,20 @@ describe('', () => { }); it('does not render publish history group when list is empty', async () => { - renderComponent(mockLibraryBlockCreationEntry.usageKeyEmpty); + renderComponent(apiMocks.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); + renderComponent(apiMocks.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 = [ + const originalData = apiMocks.mockLibraryBlockDraftHistory.data; + apiMocks.mockLibraryBlockDraftHistory.data = [ { contributor: mockContributorNoUsername(), changedAt: '2026-03-16T11:00:00Z', @@ -116,30 +106,30 @@ describe('', () => { action: 'edited', }, ]; - renderComponent(mockLibraryBlockCreationEntry.usageKey); + renderComponent(apiMocks.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; + apiMocks.mockLibraryBlockDraftHistory.data = originalData; }); it('shows fallback "Author" in publish group header when publishedBy is undefined', async () => { - const originalData = mockLibraryBlockPublishHistory.data; - mockLibraryBlockPublishHistory.data = [ + const originalData = apiMocks.mockLibraryBlockPublishHistory.data; + apiMocks.mockLibraryBlockPublishHistory.data = [ { ...originalData[0], publishedBy: undefined, }, ]; - renderComponent(mockLibraryBlockCreationEntry.usageKey); + renderComponent(apiMocks.mockLibraryBlockCreationEntry.usageKey); expect(await findByDeepTextContent(/Author published.*Protons/i)).toBeInTheDocument(); - mockLibraryBlockPublishHistory.data = originalData; + apiMocks.mockLibraryBlockPublishHistory.data = originalData; }); it('renders draft entry with "created" action', async () => { const user = userEvent.setup(); - const originalData = mockLibraryBlockDraftHistory.data; - mockLibraryBlockDraftHistory.data = [ + const originalData = apiMocks.mockLibraryBlockDraftHistory.data; + apiMocks.mockLibraryBlockDraftHistory.data = [ { contributor: { username: 'creator_user', @@ -156,27 +146,27 @@ describe('', () => { action: 'created', }, ] as any; - renderComponent(mockLibraryBlockCreationEntry.usageKey); + renderComponent(apiMocks.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; + apiMocks.mockLibraryBlockDraftHistory.data = originalData; }); it('renders publish group without collapsible and without contributor count when contributors is empty', async () => { - const originalData = mockLibraryBlockPublishHistory.data; - mockLibraryBlockPublishHistory.data = [ + const originalData = apiMocks.mockLibraryBlockPublishHistory.data; + apiMocks.mockLibraryBlockPublishHistory.data = [ { ...originalData[0], contributors: [], }, ]; - renderComponent(mockLibraryBlockCreationEntry.usageKey); + renderComponent(apiMocks.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; + apiMocks.mockLibraryBlockPublishHistory.data = originalData; }); }); @@ -186,13 +176,13 @@ describe('', () => { }); it('shows loading spinner while fetching', () => { - renderContainerComponent(mockLibraryContainerDraftHistory.containerKeyThatNeverLoads); + renderContainerComponent(apiMocks.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); + renderContainerComponent(apiMocks.mockLibraryContainerDraftHistory.containerKey); const trigger = await findByDeepTextContent(/Test Unit is a draft/i); expect(trigger).toBeInTheDocument(); await user.click(trigger); @@ -201,27 +191,27 @@ describe('', () => { }); it('does not render draft history group when there are no draft entries', async () => { - renderContainerComponent(mockLibraryContainerDraftHistory.containerKeyEmpty); + renderContainerComponent(apiMocks.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); + renderContainerComponent(apiMocks.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); + renderContainerComponent(apiMocks.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 = [ + const originalData = apiMocks.mockLibraryContainerDraftHistory.data; + apiMocks.mockLibraryContainerDraftHistory.data = [ { contributor: { username: 'creator_user', @@ -238,46 +228,46 @@ describe('', () => { action: 'created', }, ] as any; - renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + renderContainerComponent(apiMocks.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; + apiMocks.mockLibraryContainerDraftHistory.data = originalData; }); it('always renders the created group', async () => { - renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + renderContainerComponent(apiMocks.mockLibraryContainerDraftHistory.containerKey); expect(await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i)).toBeInTheDocument(); }); it('renders publish groups after container creation before the created entry', async () => { // Default mock: publishedAt '2026-03-14' is after createdAt '2024-01-01' - renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + renderContainerComponent(apiMocks.mockLibraryContainerDraftHistory.containerKey); const publishGroup = await findByDeepTextContent(/container_author published.*Intro Unit/i); const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); expect(publishGroup.compareDocumentPosition(createdEntry)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); it('renders publish groups before container creation after the created entry', async () => { - const originalData = mockLibraryContainerPublishHistory.data; - mockLibraryContainerPublishHistory.data = [ + const originalData = apiMocks.mockLibraryContainerPublishHistory.data; + apiMocks.mockLibraryContainerPublishHistory.data = [ { ...originalData[0], publishedAt: '2023-01-01T00:00:00Z', // before createdAt '2024-01-01' }, ]; - renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + renderContainerComponent(apiMocks.mockLibraryContainerDraftHistory.containerKey); const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); expect(await screen.findByText(/2 authors contributed/i)).toBeInTheDocument(); // The publish group contributors should still appear const contributorsText = screen.getByText(/2 authors contributed/i); expect(createdEntry.compareDocumentPosition(contributorsText)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); - mockLibraryContainerPublishHistory.data = originalData; + apiMocks.mockLibraryContainerPublishHistory.data = originalData; }); it('renders both pre-creation and post-creation publish groups in correct order', async () => { - const originalData = mockLibraryContainerPublishHistory.data; - mockLibraryContainerPublishHistory.data = [ + const originalData = apiMocks.mockLibraryContainerPublishHistory.data; + apiMocks.mockLibraryContainerPublishHistory.data = [ { ...originalData[0], publishLogUuid: 'after-uuid', @@ -297,7 +287,7 @@ describe('', () => { contributors: [], }, ]; - renderContainerComponent(mockLibraryContainerDraftHistory.containerKey); + renderContainerComponent(apiMocks.mockLibraryContainerDraftHistory.containerKey); const afterGroup = await findByDeepTextContent(/container_author published.*After Unit/i); const createdEntry = await findByDeepTextContent(/author created.*Introduction to Testing Unit 1/i); // After-creation group comes before the created entry @@ -305,6 +295,6 @@ describe('', () => { // Before-creation group title is hidden (hideLogVert=true, no contributors), so only the vert line renders // Verify the after-group is visible but before-group title is not expect(screen.queryByText(/container_author published.*Before Unit/i)).not.toBeInTheDocument(); - mockLibraryContainerPublishHistory.data = originalData; + apiMocks.mockLibraryContainerPublishHistory.data = originalData; }); }); diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx index 9e2b192175..8007559e24 100644 --- a/src/library-authoring/generic/history-log/HistoryLog.tsx +++ b/src/library-authoring/generic/history-log/HistoryLog.tsx @@ -13,6 +13,13 @@ import { HistoryCreatedLogGroup, HistoryDraftLogGroup, HistoryPublishLogGroup } import { useMemo } from 'react'; import { LibraryPublishHistoryGroup } from '@src/library-authoring/data/api'; +/** + * History log for a single component (block). + * + * Renders, top to bottom: pending draft edits, publish groups, and the creation entry. + * For the container equivalent see HistoryContainerLog, which adds logic to split publish + * groups into those before and after the container's creation time. + */ export const HistoryComponentLog = ({ componentId }: { componentId: string; }) => { const { data: draftHistory, @@ -68,6 +75,17 @@ export const HistoryComponentLog = ({ componentId }: { componentId: string; }) = ); }; +/** + * History log for a container (e.g. a Unit). + * + * Similar to HistoryComponentLog but uses container-specific API hooks and splits publish groups + * into those after and before the container's creation time. The latter can occur when the container + * has children that were published before the container itself was created. + * These "before creation" groups are rendered below the creation entry. + * + * These two components are kept separate because the hooks they use are different and the + * before/after creation split logic only applies to containers. + */ export const HistoryContainerLog = ({ containerId }: { containerId: string; }) => { const { data: draftHistory, diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 83fafdd787..5277dd92c9 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -25,6 +25,7 @@ export interface HistoryCreatedLogGroupProps { displayName: string; itemType: string; createdAt: string; + // When true, renders a vertical connector line below the creation entry to link it to publish groups that predate the container's creation. showLogVert?: boolean; } @@ -35,11 +36,12 @@ export interface HistoryDraftLogGroupProps { export interface HistoryLogGroupEntriesProps { entries: LibraryHistoryEntry[]; - hideLastLogVert?: boolean; } export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup { itemId: string; + // When true, hides the vertical connector line rendered below this group. Used for the last group + // in the history log to avoid a dangling connector with nothing below it. hideLogVert?: boolean; } @@ -105,7 +107,6 @@ const HistoryLogGroupTitle = ({ const HistoryLogGroupEntries = ({ entries, - hideLastLogVert, }: HistoryLogGroupEntriesProps) => { const intl = useIntl(); @@ -154,7 +155,7 @@ const HistoryLogGroupEntries = ({
    - {!isLast && !hideLastLogVert &&
    } + {!isLast &&
    }
    ); })} From 66d1b91b8e3c5fa0c83e9d5d2f9362958aa0d190 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Wed, 29 Apr 2026 16:37:07 -0500 Subject: [PATCH 13/13] refactor: Moving components from HistoryLogGroup.tsx to ContributorAvatars.tsx and HistoryLogGroupEntries.tsx --- .../history-log/ContributorAvatars.tsx | 62 +++++++++ .../generic/history-log/HistoryLogGroup.tsx | 127 +----------------- .../history-log/HistoryLogGroupEntries.tsx | 69 ++++++++++ 3 files changed, 136 insertions(+), 122 deletions(-) create mode 100644 src/library-authoring/generic/history-log/ContributorAvatars.tsx create mode 100644 src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx diff --git a/src/library-authoring/generic/history-log/ContributorAvatars.tsx b/src/library-authoring/generic/history-log/ContributorAvatars.tsx new file mode 100644 index 0000000000..598f1698c4 --- /dev/null +++ b/src/library-authoring/generic/history-log/ContributorAvatars.tsx @@ -0,0 +1,62 @@ +import { ComponentProps, useState } from 'react'; +import { Avatar, Stack } from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { LibraryPublishContributor } from '@src/library-authoring/data/api'; +import messages from './messages'; + +const MAX_VISIBLE_CONTRIBUTORS = 5; + +interface ContributorAvatarProps { + username?: string; + src?: string; + className: string; + size: ComponentProps['size']; +} + +interface ContributorsAvatarsProps { + contributors: LibraryPublishContributor[]; +} + +export const ContributorAvatar = ({ + username, + src, + className, + size, +}: ContributorAvatarProps) => { + const intl = useIntl(); + const [imgError, setImgError] = useState(false); + return ( + setImgError(true)} + /> + ); +}; + +export const ContributorsAvatars = ({ contributors }: ContributorsAvatarsProps) => { + const visible = contributors.slice(0, MAX_VISIBLE_CONTRIBUTORS); + return ( + +
    + {visible.map(({ username, profileImageUrls }) => ( + + ))} +
    + + + +
    + ); +}; diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 5277dd92c9..033b151ffd 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -1,18 +1,18 @@ -import { ComponentProps, ReactNode, useState } from 'react'; +import { ReactNode } 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 { 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 { LibraryHistoryEntry, LibraryPublishHistoryGroup } from '../../data/api'; import messages from './messages'; import { getItemIcon } from '@src/generic/block-type-utils'; - -const MAX_VISIBLE_CONTRIBUTORS = 5; +import { ContributorsAvatars } from './ContributorAvatars'; +import { HistoryLogGroupEntries } from './HistoryLogGroupEntries'; export interface HistoryLogGroupTitleProps { titleMessage: string | ReactNode; @@ -34,10 +34,6 @@ export interface HistoryDraftLogGroupProps { entries: LibraryHistoryEntry[]; } -export interface HistoryLogGroupEntriesProps { - entries: LibraryHistoryEntry[]; -} - export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup { itemId: string; // When true, hides the vertical connector line rendered below this group. Used for the last group @@ -45,36 +41,6 @@ export interface HistoryPublishLogGroupProps extends LibraryPublishHistoryGroup hideLogVert?: boolean; } -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, @@ -105,64 +71,6 @@ const HistoryLogGroupTitle = ({ ); }; -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, index, arr) => { - const isLast = index === arr.length - 1; - const entryMessage = getEntryMessage(entry); - - return ( -
    - - - - - {entry.title}, - icon: , - }} - /> - - - {moment(entry.changedAt).fromNow()} - - - - {!isLast &&
    } -
    - ); - })} - - ); -}; - export const HistoryCreatedLogGroup = ({ user, displayName, @@ -223,31 +131,6 @@ export const HistoryDraftLogGroup = ({ ); }; -const ContributorsAvatars = ({ contributors }: ContributorsAvatarsProps) => { - const visible = contributors.slice(0, MAX_VISIBLE_CONTRIBUTORS); - return ( - -
    - {visible.map(({ username, profileImageUrls }) => ( - - ))} -
    - - - -
    - ); -}; - export const HistoryPublishLogGroup = ({ itemId, publishLogUuid, diff --git a/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx new file mode 100644 index 0000000000..05eb175bb6 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx @@ -0,0 +1,69 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { LibraryHistoryEntry } from '@src/library-authoring/data/api'; +import messages from './messages'; +import { Icon, Stack } from '@openedx/paragon'; +import { ContributorAvatar } from './ContributorAvatars'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import moment from 'moment'; + +export interface HistoryLogGroupEntriesProps { + entries: LibraryHistoryEntry[]; +} + +export 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, index, arr) => { + const isLast = index === arr.length - 1; + const entryMessage = getEntryMessage(entry); + + return ( +
    + + + + + {entry.title}, + icon: , + }} + /> + + + {moment(entry.changedAt).fromNow()} + + + + {!isLast &&
    } +
    + ); + })} + + ); +};