diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx new file mode 100644 index 0000000000..b885cf7ef2 --- /dev/null +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -0,0 +1,42 @@ +import { + initializeMocks, + render, + screen, +} from '../../testUtils'; +import { mockLibraryBlockMetadata } from '../data/api.mocks'; +import ComponentDetails from './ComponentDetails'; + +describe('', () => { + it('should render the component details loading', async () => { + initializeMocks(); + mockLibraryBlockMetadata.applyMock(); + render(); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + }); + + it('should render the component details error', async () => { + initializeMocks(); + mockLibraryBlockMetadata.applyMock(); + render(); + expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument(); + }); + + it('should render the component usage', async () => { + initializeMocks(); + mockLibraryBlockMetadata.applyMock(); + render(); + expect(await screen.findByText('Component Usage')).toBeInTheDocument(); + // TODO: replace with actual data when implement tag list + expect(screen.queryByText('This will show the courses that use this component.')).toBeInTheDocument(); + }); + + it('should render the component history', async () => { + initializeMocks(); + mockLibraryBlockMetadata.applyMock(); + render(); + // Show created date + expect(await screen.findByText('June 20, 2024')).toBeInTheDocument(); + // Show modified date + expect(await screen.findByText('June 21, 2024')).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx new file mode 100644 index 0000000000..a5481eb22e --- /dev/null +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -0,0 +1,57 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; + +import AlertError from '../../generic/alert-error'; +import Loading from '../../generic/Loading'; +import { useLibraryBlockMetadata } from '../data/apiHooks'; +import HistoryWidget from '../generic/history-widget'; +import { ComponentDeveloperInfo } from './ComponentDeveloperInfo'; +import messages from './messages'; + +interface ComponentDetailsProps { + usageKey: string; +} + +const ComponentDetails = ({ usageKey }: ComponentDetailsProps) => { + const intl = useIntl(); + const { + data: componentMetadata, + isError, + error, + isLoading, + } = useLibraryBlockMetadata(usageKey); + + if (isError) { + return ; + } + + if (isLoading) { + return ; + } + + return ( + +
+

+ {intl.formatMessage(messages.detailsTabUsageTitle)} +

+ This will show the courses that use this component. +
+
+
+

+ {intl.formatMessage(messages.detailsTabHistoryTitle)} +

+ +
+ { + // istanbul ignore next: this is only shown in development + (process.env.NODE_ENV === 'development' ? : null) + } +
+ ); +}; + +export default ComponentDetails; diff --git a/src/library-authoring/component-info/ComponentDeveloperInfo.tsx b/src/library-authoring/component-info/ComponentDeveloperInfo.tsx index 430d9a7636..8e73d1fdfb 100644 --- a/src/library-authoring/component-info/ComponentDeveloperInfo.tsx +++ b/src/library-authoring/component-info/ComponentDeveloperInfo.tsx @@ -14,7 +14,7 @@ export const ComponentDeveloperInfo: React.FC = ({ usageKey }) => { const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); return ( <> -
+

Developer Component Details

(This panel is only visible in development builds.)

diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 735257d732..f503e4df99 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -10,7 +10,7 @@ import { Link } from 'react-router-dom'; import { getEditUrl } from '../components/utils'; import { ComponentMenu } from '../components'; -import { ComponentDeveloperInfo } from './ComponentDeveloperInfo'; +import ComponentDetails from './ComponentDetails'; import ComponentManagement from './ComponentManagement'; import ComponentPreview from './ComponentPreview'; import messages from './messages'; @@ -50,11 +50,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => { - Details tab placeholder - - { - (process.env.NODE_ENV === 'development' ? : null) - } + diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx index b56d33bb0f..b069343016 100644 --- a/src/library-authoring/component-info/ComponentManagement.test.tsx +++ b/src/library-authoring/component-info/ComponentManagement.test.tsx @@ -9,7 +9,7 @@ import { mockLibraryBlockMetadata } from '../data/api.mocks'; import ComponentManagement from './ComponentManagement'; /* - * FIXME: Summarize the reason here + * This function is used to get the inner text of an element. * https://stackoverflow.com/questions/47902335/innertext-is-undefined-in-jest-test */ const getInnerText = (element: Element) => element?.textContent diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx index 172f8331da..12a9cea75c 100644 --- a/src/library-authoring/component-info/ComponentManagement.tsx +++ b/src/library-authoring/component-info/ComponentManagement.tsx @@ -14,6 +14,7 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => { const intl = useIntl(); const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); + // istanbul ignore if: this should never happen if (!componentMetadata) { return null; } diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 8a9404081e..c24146160e 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -51,6 +51,16 @@ const messages = defineMessages({ defaultMessage: 'Details', description: 'Title for details tab', }, + detailsTabUsageTitle: { + id: 'course-authoring.library-authoring.component.details-tab.usage-title', + defaultMessage: 'Component Usage', + description: 'Title for the Component Usage container in the details tab', + }, + detailsTabHistoryTitle: { + id: 'course-authoring.library-authoring.component.details-tab.history-title', + defaultMessage: 'Component History', + description: 'Title for the Component History container in the details tab', + }, previewExpandButtonTitle: { id: 'course-authoring.library-authoring.component.preview.expand.title', defaultMessage: 'Expand', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 78ab2f3f53..505a9d1d16 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -134,6 +134,7 @@ mockCreateLibraryBlock.newHtmlData = { lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, created: '2024-07-22T21:37:49Z', + modified: '2024-07-22T21:37:49Z', tagsCount: 0, } satisfies api.LibraryBlockMetadata; mockCreateLibraryBlock.newProblemData = { @@ -147,6 +148,7 @@ mockCreateLibraryBlock.newProblemData = { lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, created: '2024-07-22T21:37:49Z', + modified: '2024-07-22T21:37:49Z', tagsCount: 0, } satisfies api.LibraryBlockMetadata; mockCreateLibraryBlock.newVideoData = { @@ -160,6 +162,7 @@ mockCreateLibraryBlock.newVideoData = { lastDraftCreated: '2024-07-22T21:37:49Z', lastDraftCreatedBy: null, created: '2024-07-22T21:37:49Z', + modified: '2024-07-22T21:37:49Z', tagsCount: 0, } satisfies api.LibraryBlockMetadata; /** Apply this mock. Returns a spy object that can tell you if it's been called. */ @@ -224,11 +227,18 @@ mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplem export async function mockLibraryBlockMetadata(usageKey: string): Promise { const thisMock = mockLibraryBlockMetadata; switch (usageKey) { + case thisMock.usageKeyThatNeverLoads: + // Return a promise that never resolves, to simulate never loading: + return new Promise(() => {}); + case thisMock.usageKeyError404: + throw createAxiosError({ code: 404, message: 'Not found.', path: api.getLibraryBlockMetadataUrl(usageKey) }); case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished; case thisMock.usageKeyPublished: return thisMock.dataPublished; default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`); } } +mockLibraryBlockMetadata.usageKeyThatNeverLoads = 'lb:Axim:infiniteLoading:html:123'; +mockLibraryBlockMetadata.usageKeyError404 = 'lb:Axim:error404:html:123'; mockLibraryBlockMetadata.usageKeyNeverPublished = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1'; mockLibraryBlockMetadata.dataNeverPublished = { id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1', @@ -241,6 +251,7 @@ mockLibraryBlockMetadata.dataNeverPublished = { lastDraftCreatedBy: null, hasUnpublishedChanges: false, created: '2024-06-20T13:54:21Z', + modified: '2024-06-21T13:54:21Z', tagsCount: 0, } satisfies api.LibraryBlockMetadata; mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2'; @@ -255,6 +266,7 @@ mockLibraryBlockMetadata.dataPublished = { lastDraftCreatedBy: '2024-06-20T20:00:00Z', hasUnpublishedChanges: false, created: '2024-06-20T13:54:21Z', + modified: '2024-06-21T13:54:21Z', tagsCount: 0, } satisfies api.LibraryBlockMetadata; /** Apply this mock. Returns a spy object that can tell you if it's been called. */ diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 894151e903..970a79a96e 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -149,6 +149,7 @@ export interface LibraryBlockMetadata { lastDraftCreatedBy: string | null, hasUnpublishedChanges: boolean; created: string | null, + modified: string | null, tagsCount: number; } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index ef48443c3c..96b7122af8 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -95,6 +95,7 @@ export const xblockQueryKeys = { */ export function invalidateComponentData(queryClient: QueryClient, contentLibraryId: string, usageKey: string) { queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) }); + queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) }); queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) }); } diff --git a/src/library-authoring/generic/history-widget/HistoryWidget.scss b/src/library-authoring/generic/history-widget/HistoryWidget.scss new file mode 100644 index 0000000000..84e11cf60a --- /dev/null +++ b/src/library-authoring/generic/history-widget/HistoryWidget.scss @@ -0,0 +1,6 @@ +.history-widget-bar { + border-left: 8px solid $info-300; + border-radius: 4px; + padding-left: 1rem; +} + diff --git a/src/library-authoring/generic/history-widget/index.tsx b/src/library-authoring/generic/history-widget/index.tsx new file mode 100644 index 0000000000..615a570504 --- /dev/null +++ b/src/library-authoring/generic/history-widget/index.tsx @@ -0,0 +1,52 @@ +import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; + +import messages from './messages'; + +const CustomFormattedDate = ({ date }: { date: string }) => ( + +); + +type HistoryWidgedProps = { + modified: string | null; + created: string | null; +}; + +/** + * This component displays the history of an entity (Last Modified and Created dates) + * + * This component doesn't handle fetching the data or any other side effects. It only displays the dates. + * + * @example + * ```tsx + * const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); + * + * return ; + * ``` + */ +const HistoryWidget = ({ + modified, + created, +}: HistoryWidgedProps) => ( + + {modified && ( +
+
+ +
+ )} + {created && ( +
+
+ +
+ )} +
+); + +export default HistoryWidget; diff --git a/src/library-authoring/generic/history-widget/messages.ts b/src/library-authoring/generic/history-widget/messages.ts new file mode 100644 index 0000000000..b0c84a85e7 --- /dev/null +++ b/src/library-authoring/generic/history-widget/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + lastModifiedTitle: { + id: 'course-authoring.library-authoring.generic.history-widget.last-modified', + defaultMessage: 'Last Modified', + description: 'Title of the last modified section in the library authoring sidebar.', + }, + createdTitle: { + id: 'course-authoring.library-authoring.generic.history-widget.created', + defaultMessage: 'Created', + description: 'Title of the created section in the library authoring sidebar.', + }, +}); + +export default messages; diff --git a/src/library-authoring/generic/index.scss b/src/library-authoring/generic/index.scss index 8e15b4671b..b7c9c75447 100644 --- a/src/library-authoring/generic/index.scss +++ b/src/library-authoring/generic/index.scss @@ -1 +1,2 @@ @import "./status-widget/StatusWidget"; +@import "./history-widget/HistoryWidget";