feat: add component Details sidebar [FC-0062] (#1303)
* feat: add ComponentDetails component --------- Co-authored-by: Jillian <jill@opencraft.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
} from '../../testUtils';
|
||||
import { mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import ComponentDetails from './ComponentDetails';
|
||||
|
||||
describe('<ComponentDetails />', () => {
|
||||
it('should render the component details loading', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />);
|
||||
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component details error', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />);
|
||||
expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component usage', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
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(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
// Show created date
|
||||
expect(await screen.findByText('June 20, 2024')).toBeInTheDocument();
|
||||
// Show modified date
|
||||
expect(await screen.findByText('June 21, 2024')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
src/library-authoring/component-info/ComponentDetails.tsx
Normal file
57
src/library-authoring/component-info/ComponentDetails.tsx
Normal file
@@ -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 <AlertError error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<div>
|
||||
<h3 className="h5">
|
||||
{intl.formatMessage(messages.detailsTabUsageTitle)}
|
||||
</h3>
|
||||
<small>This will show the courses that use this component.</small>
|
||||
</div>
|
||||
<hr className="w-100" />
|
||||
<div>
|
||||
<h3 className="h5">
|
||||
{intl.formatMessage(messages.detailsTabHistoryTitle)}
|
||||
</h3>
|
||||
<HistoryWidget
|
||||
{...componentMetadata}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
// istanbul ignore next: this is only shown in development
|
||||
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
|
||||
}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentDetails;
|
||||
@@ -14,7 +14,7 @@ export const ComponentDeveloperInfo: React.FC<Props> = ({ usageKey }) => {
|
||||
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<hr className="w-100" />
|
||||
<h3 className="h5">Developer Component Details</h3>
|
||||
<p><small>(This panel is only visible in development builds.)</small></p>
|
||||
<dl>
|
||||
|
||||
@@ -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) => {
|
||||
<ComponentManagement usageKey={usageKey} />
|
||||
</Tab>
|
||||
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
|
||||
Details tab placeholder
|
||||
|
||||
{
|
||||
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
|
||||
}
|
||||
<ComponentDetails usageKey={usageKey} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<api.LibraryBlockMetadata> {
|
||||
const thisMock = mockLibraryBlockMetadata;
|
||||
switch (usageKey) {
|
||||
case thisMock.usageKeyThatNeverLoads:
|
||||
// Return a promise that never resolves, to simulate never loading:
|
||||
return new Promise<any>(() => {});
|
||||
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. */
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface LibraryBlockMetadata {
|
||||
lastDraftCreatedBy: string | null,
|
||||
hasUnpublishedChanges: boolean;
|
||||
created: string | null,
|
||||
modified: string | null,
|
||||
tagsCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.history-widget-bar {
|
||||
border-left: 8px solid $info-300;
|
||||
border-radius: 4px;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
52
src/library-authoring/generic/history-widget/index.tsx
Normal file
52
src/library-authoring/generic/history-widget/index.tsx
Normal file
@@ -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 }) => (
|
||||
<FormattedDate
|
||||
value={date}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="2-digit"
|
||||
/>
|
||||
);
|
||||
|
||||
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 <HistoryWidget {...componentMetadata} />;
|
||||
* ```
|
||||
*/
|
||||
const HistoryWidget = ({
|
||||
modified,
|
||||
created,
|
||||
}: HistoryWidgedProps) => (
|
||||
<Stack className="history-widget-bar small" gap={3}>
|
||||
{modified && (
|
||||
<div>
|
||||
<div className="text-muted"><FormattedMessage {...messages.lastModifiedTitle} /> </div>
|
||||
<CustomFormattedDate date={modified} />
|
||||
</div>
|
||||
)}
|
||||
{created && (
|
||||
<div>
|
||||
<div className="text-muted"><FormattedMessage {...messages.createdTitle} /> </div>
|
||||
<CustomFormattedDate date={created} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default HistoryWidget;
|
||||
16
src/library-authoring/generic/history-widget/messages.ts
Normal file
16
src/library-authoring/generic/history-widget/messages.ts
Normal file
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
@import "./status-widget/StatusWidget";
|
||||
@import "./history-widget/HistoryWidget";
|
||||
|
||||
Reference in New Issue
Block a user