Skip to content

Commit aaee15c

Browse files
committed
feat: History log for Components
1 parent f36b869 commit aaee15c

12 files changed

Lines changed: 878 additions & 22 deletions

File tree

src/library-authoring/component-info/ComponentDetails.test.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { mockFetchIndexDocuments, mockContentSearchConfig } from '@src/search-ma
1010
import {
1111
mockContentLibrary,
1212
mockGetEntityLinks,
13+
mockLibraryBlockDraftHistory,
1314
mockLibraryBlockMetadata,
15+
mockLibraryBlockPublishHistory,
16+
mockLibraryBlockPublishHistoryEntries,
1417
mockXBlockAssets,
1518
mockXBlockOLX,
1619
} from '../data/api.mocks';
@@ -21,6 +24,9 @@ import ComponentDetails from './ComponentDetails';
2124
mockContentSearchConfig.applyMock();
2225
mockContentLibrary.applyMock();
2326
mockLibraryBlockMetadata.applyMock();
27+
mockLibraryBlockDraftHistory.applyMock();
28+
mockLibraryBlockPublishHistory.applyMock();
29+
mockLibraryBlockPublishHistoryEntries.applyMock();
2430
mockXBlockAssets.applyMock();
2531
mockXBlockOLX.applyMock();
2632
mockGetEntityLinks.applyMock();
@@ -96,11 +102,7 @@ describe('<ComponentDetails />', () => {
96102

97103
it('should render the component history', async () => {
98104
render(mockLibraryBlockMetadata.usageKeyPublished);
99-
// Show created date
100-
expect(await screen.findByText('June 20, 2024')).toBeInTheDocument();
101-
// Show modified date
102-
expect(await screen.findByText('June 21, 2024')).toBeInTheDocument();
103-
// Show last published date
104-
expect(await screen.findByText('June 22, 2024')).toBeInTheDocument();
105+
// Show created group (no draft or publish history for this usage key)
106+
expect(await screen.findByText(/Author created this component/i)).toBeInTheDocument();
105107
});
106108
});

src/library-authoring/component-info/ComponentDetails.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { FormattedMessage } from '@edx/frontend-platform/i18n';
22
import { Stack } from '@openedx/paragon';
33

4-
import AlertError from '../../generic/alert-error';
5-
import Loading from '../../generic/Loading';
4+
import AlertError from '@src/generic/alert-error';
5+
import Loading from '@src/generic/Loading';
66
import { useSidebarContext } from '../common/context/SidebarContext';
77
import { useLibraryBlockMetadata } from '../data/apiHooks';
8-
import HistoryWidget from '../generic/history-widget';
98
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
109
import { ComponentUsage } from './ComponentUsage';
1110
import messages from './messages';
11+
import { HistoryComponentLog } from '../generic/history-log/HistoryLog';
1212

1313
const ComponentDetails = () => {
1414
const { sidebarItemInfo } = useSidebarContext();
@@ -48,7 +48,10 @@ const ComponentDetails = () => {
4848
<h3 className="h5">
4949
<FormattedMessage {...messages.detailsTabHistoryTitle} />
5050
</h3>
51-
<HistoryWidget {...componentMetadata} />
51+
<HistoryComponentLog
52+
componentId={usageKey}
53+
displayName={componentMetadata.displayName}
54+
/>
5255
</>
5356
<ComponentAdvancedInfo />
5457
</Stack>

src/library-authoring/data/api.mocks.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ mockCreateLibraryBlock.newHtmlData = {
258258
publishedBy: null, // or e.g. 'test_author',
259259
lastDraftCreated: '2024-07-22T21:37:49Z',
260260
lastDraftCreatedBy: null,
261+
createdBy: null,
261262
created: '2024-07-22T21:37:49Z',
262263
modified: '2024-07-22T21:37:49Z',
263264
tagsCount: 0,
@@ -273,6 +274,7 @@ mockCreateLibraryBlock.newProblemData = {
273274
publishedBy: null, // or e.g. 'test_author',
274275
lastDraftCreated: '2024-07-22T21:37:49Z',
275276
lastDraftCreatedBy: null,
277+
createdBy: null,
276278
created: '2024-07-22T21:37:49Z',
277279
modified: '2024-07-22T21:37:49Z',
278280
tagsCount: 0,
@@ -288,6 +290,7 @@ mockCreateLibraryBlock.newVideoData = {
288290
publishedBy: null, // or e.g. 'test_author',
289291
lastDraftCreated: '2024-07-22T21:37:49Z',
290292
lastDraftCreatedBy: null,
293+
createdBy: null,
291294
created: '2024-07-22T21:37:49Z',
292295
modified: '2024-07-22T21:37:49Z',
293296
tagsCount: 0,
@@ -459,6 +462,7 @@ mockLibraryBlockMetadata.dataNeverPublished = {
459462
lastDraftCreated: null,
460463
lastDraftCreatedBy: null,
461464
hasUnpublishedChanges: true,
465+
createdBy: null,
462466
created: '2024-06-20T13:54:21Z',
463467
modified: '2024-06-21T13:54:21Z',
464468
tagsCount: 0,
@@ -478,6 +482,7 @@ mockLibraryBlockMetadata.dataPublished = {
478482
created: '2024-06-20T13:54:21Z',
479483
modified: '2024-06-21T13:54:21Z',
480484
tagsCount: 0,
485+
createdBy: null,
481486
collections: [],
482487
} satisfies api.LibraryBlockMetadata;
483488
mockLibraryBlockMetadata.usageKeyPublishDisabled = 'lb:Axim:TEST2-disabled:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
@@ -504,6 +509,7 @@ mockLibraryBlockMetadata.dataWithCollections = {
504509
lastDraftCreated: null,
505510
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
506511
hasUnpublishedChanges: false,
512+
createdBy: null,
507513
created: '2024-06-20T13:54:21Z',
508514
modified: '2024-06-21T13:54:21Z',
509515
tagsCount: 0,
@@ -521,6 +527,7 @@ mockLibraryBlockMetadata.dataPublishedWithChanges = {
521527
lastDraftCreated: null,
522528
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
523529
hasUnpublishedChanges: true,
530+
createdBy: null,
524531
created: '2024-06-20T13:54:21Z',
525532
modified: '2024-06-23T13:54:21Z',
526533
tagsCount: 0,
@@ -741,6 +748,7 @@ mockGetContainerChildren.childTemplate = {
741748
publishedBy: null,
742749
lastDraftCreated: null,
743750
lastDraftCreatedBy: null,
751+
createdBy: null,
744752
hasUnpublishedChanges: false,
745753
created: null,
746754
modified: null,
@@ -1229,6 +1237,136 @@ mockGetCourseImports.applyMock = () =>
12291237
'getCourseImports',
12301238
).mockImplementation(mockGetCourseImports);
12311239

1240+
/**
1241+
* Mock for `getLibraryBlockDraftHistory()`
1242+
*
1243+
* Use `mockLibraryBlockDraftHistory.applyMock()` to apply it to the whole test suite.
1244+
*/
1245+
export async function mockLibraryBlockDraftHistory(usageKey: string): Promise<api.LibraryHistoryEntry[]> {
1246+
const thisMock = mockLibraryBlockDraftHistory;
1247+
switch (usageKey) {
1248+
case thisMock.usageKey: return thisMock.data;
1249+
case thisMock.usageKeyEmpty: return [];
1250+
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
1251+
}
1252+
}
1253+
mockLibraryBlockDraftHistory.usageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
1254+
mockLibraryBlockDraftHistory.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
1255+
const mockContributor = (username: string): api.LibraryPublishContributor => ({
1256+
username,
1257+
profileImageUrls: {
1258+
full: 'icon/mock/path',
1259+
large: 'icon/mock/path',
1260+
medium: 'icon/mock/path',
1261+
small: 'icon/mock/path',
1262+
},
1263+
});
1264+
1265+
mockLibraryBlockDraftHistory.data = [
1266+
{
1267+
changedBy: mockContributor('test_user_1'),
1268+
changedAt: '2026-03-16T11:00:00Z',
1269+
title: 'Electron Arcs',
1270+
action: 'edited',
1271+
blockType: 'html',
1272+
},
1273+
{
1274+
changedBy: mockContributor('test_user_2'),
1275+
changedAt: '2026-03-13T10:00:00Z',
1276+
title: 'More on Quarks',
1277+
action: 'renamed',
1278+
blockType: 'html',
1279+
},
1280+
] satisfies api.LibraryHistoryEntry[];
1281+
mockLibraryBlockDraftHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockDraftHistory').mockImplementation(mockLibraryBlockDraftHistory);
1282+
1283+
/**
1284+
* Mock for `getLibraryBlockPublishHistory()`
1285+
*
1286+
* Use `mockLibraryBlockPublishHistory.applyMock()` to apply it to the whole test suite.
1287+
*/
1288+
export async function mockLibraryBlockPublishHistory(usageKey: string): Promise<api.LibraryPublishHistoryGroup[]> {
1289+
const thisMock = mockLibraryBlockPublishHistory;
1290+
switch (usageKey) {
1291+
case thisMock.usageKeyWithGroups: return thisMock.data;
1292+
case thisMock.usageKeyEmpty: return [];
1293+
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
1294+
}
1295+
}
1296+
mockLibraryBlockPublishHistory.usageKeyWithGroups = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
1297+
mockLibraryBlockPublishHistory.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
1298+
mockLibraryBlockPublishHistory.data = [
1299+
{
1300+
publishLogUuid: 'abc-123',
1301+
title: 'Protons',
1302+
blockType: 'html',
1303+
publishedBy: 'author',
1304+
publishedAt: '2026-03-14T10:00:00Z',
1305+
contributors: ['test_user_1', 'test_user_2', 'test_user_3', 'test_user_4', 'test_user_5'].map(mockContributor),
1306+
contributorsCount: 5,
1307+
},
1308+
] satisfies api.LibraryPublishHistoryGroup[];
1309+
mockLibraryBlockPublishHistory.applyMock = () => jest.spyOn(api, 'getLibraryBlockPublishHistory').mockImplementation(mockLibraryBlockPublishHistory);
1310+
1311+
/**
1312+
* Mock for `getLibraryBlockPublishHistoryEntries()`
1313+
*
1314+
* Use `mockLibraryBlockPublishHistoryEntries.applyMock()` to apply it to the whole test suite.
1315+
*/
1316+
export async function mockLibraryBlockPublishHistoryEntries(
1317+
_usageKey: string,
1318+
_publishGroupId: string,
1319+
): Promise<api.LibraryHistoryEntry[]> {
1320+
return mockLibraryBlockPublishHistoryEntries.data;
1321+
}
1322+
mockLibraryBlockPublishHistoryEntries.data = [
1323+
{
1324+
changedBy: mockContributor('test_user'),
1325+
changedAt: '2026-03-10T09:00:00Z',
1326+
title: 'Protons',
1327+
action: 'edited',
1328+
blockType: 'html',
1329+
},
1330+
] satisfies api.LibraryHistoryEntry[];
1331+
mockLibraryBlockPublishHistoryEntries.applyMock = () => jest.spyOn(
1332+
api,
1333+
'getLibraryBlockPublishHistoryEntries',
1334+
).mockImplementation(mockLibraryBlockPublishHistoryEntries);
1335+
1336+
/**
1337+
* Mock for `getLibraryBlockCreationEntry()`
1338+
*
1339+
* Use `mockLibraryBlockCreationEntry.applyMock()` to apply it to the whole test suite.
1340+
*/
1341+
export async function mockLibraryBlockCreationEntry(usageKey: string): Promise<api.LibraryHistoryEntry> {
1342+
const thisMock = mockLibraryBlockCreationEntry;
1343+
switch (usageKey) {
1344+
case thisMock.usageKeyThatNeverLoads:
1345+
return new Promise<any>(() => {});
1346+
case thisMock.usageKey: return thisMock.data;
1347+
case thisMock.usageKeyEmpty: return thisMock.dataEmpty;
1348+
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
1349+
}
1350+
}
1351+
mockLibraryBlockCreationEntry.usageKeyThatNeverLoads = 'lb:Axim:infiniteLoading:html:123';
1352+
mockLibraryBlockCreationEntry.usageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
1353+
mockLibraryBlockCreationEntry.usageKeyEmpty = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
1354+
mockLibraryBlockCreationEntry.data = {
1355+
changedBy: mockContributor('author'),
1356+
changedAt: '2024-01-01T00:00:00Z',
1357+
title: 'Introduction to Testing 1',
1358+
blockType: 'html',
1359+
action: 'created',
1360+
} satisfies api.LibraryHistoryEntry;
1361+
mockLibraryBlockCreationEntry.dataEmpty = {
1362+
changedBy: mockContributor('Author'),
1363+
changedAt: '2024-01-01T00:00:00Z',
1364+
title: 'Introduction to Testing 2',
1365+
blockType: 'html',
1366+
action: 'created',
1367+
} satisfies api.LibraryHistoryEntry;
1368+
mockLibraryBlockCreationEntry.applyMock = () => jest.spyOn(api, 'getLibraryBlockCreationEntry').mockImplementation(mockLibraryBlockCreationEntry);
1369+
12321370
export const mockGetMigrationInfo = {
12331371
applyMock: () =>
12341372
jest.spyOn(api, 'getMigrationInfo').mockResolvedValue(

src/library-authoring/data/api.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,26 @@ export const getLibraryBlockCollectionsUrl = (usageKey: string) =>
5555
*/
5656
export const getLibraryBlockHierarchyUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}hierarchy/`;
5757

58+
/**
59+
* Get the URL for the component draft history.
60+
*/
61+
export const getLibraryBlockDraftHistoryUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}draft_history/`;
62+
63+
/**
64+
* Get the URL for the component publish history.
65+
*/
66+
export const getLibraryBlockPublishHistoryUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}publish_history/`;
67+
68+
/**
69+
* Get the URL for the entries of a publish group.
70+
*/
71+
export const getLibraryBlockPublishHistoryEntriesUrl = (usageKey: string, publishGroupId: string) => `${getLibraryBlockMetadataUrl(usageKey)}publish_history/${publishGroupId}/entries/`
72+
73+
/**
74+
* Get the URL for the creation entry of a component.
75+
*/
76+
export const getLibraryBlockCreationEntryUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}creation_entry/`;
77+
5878
/**
5979
* Get the URL for content library list API.
6080
*/
@@ -332,6 +352,7 @@ export interface LibraryBlockMetadata {
332352
lastDraftCreatedBy: string | null;
333353
hasUnpublishedChanges: boolean;
334354
created: string | null;
355+
createdBy: string | null;
335356
modified: string | null;
336357
tagsCount: number;
337358
collections: CollectionMetadata[];
@@ -932,3 +953,63 @@ export async function getModulestoreMigrationBlocksInfo(
932953
const { data } = await client.get(getModulestoreMigratedBlocksInfoUrl(), { params });
933954
return camelCaseObject(data);
934955
}
956+
957+
export interface LibraryPublishHistoryGroup {
958+
publishLogUuid: string;
959+
title: string;
960+
publishedBy: string;
961+
publishedAt: string;
962+
blockType: string;
963+
contributors: LibraryPublishContributor[];
964+
contributorsCount: number;
965+
}
966+
967+
export interface LibraryPublishContributor {
968+
profileImageUrls: {
969+
full: string;
970+
large: string;
971+
medium: string;
972+
small: string;
973+
};
974+
username: string;
975+
}
976+
977+
export interface LibraryHistoryEntry {
978+
changedBy: LibraryPublishContributor;
979+
changedAt: string;
980+
title: string;
981+
blockType: string;
982+
action: 'edited' | 'renamed' | 'created';
983+
}
984+
985+
/**
986+
* Get the publish history for a library block.
987+
*/
988+
export async function getLibraryBlockPublishHistory(usageKey: string): Promise<LibraryPublishHistoryGroup[]> {
989+
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockPublishHistoryUrl(usageKey));
990+
return camelCaseObject(data);
991+
}
992+
993+
/**
994+
* Get the entries for a publish history group of a library block.
995+
*/
996+
export async function getLibraryBlockPublishHistoryEntries(usageKey: string, publishGroupId: string): Promise<LibraryHistoryEntry[]> {
997+
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockPublishHistoryEntriesUrl(usageKey, publishGroupId));
998+
return camelCaseObject(data);
999+
}
1000+
1001+
/**
1002+
* Get the draft history for a library block.
1003+
*/
1004+
export async function getLibraryBlockDraftHistory(usageKey: string): Promise<LibraryHistoryEntry[]> {
1005+
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockDraftHistoryUrl(usageKey));
1006+
return camelCaseObject(data);
1007+
}
1008+
1009+
/**
1010+
* Get the creation entry for a library block.
1011+
*/
1012+
export async function getLibraryBlockCreationEntry(usageKey: string): Promise<LibraryHistoryEntry> {
1013+
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockCreationEntryUrl(usageKey));
1014+
return camelCaseObject(data);
1015+
}

0 commit comments

Comments
 (0)