feat: add component Details sidebar [FC-0062] (#1303)

* feat: add ComponentDetails component

---------

Co-authored-by: Jillian <jill@opencraft.com>
This commit is contained in:
Rômulo Penido
2024-09-25 16:33:45 -03:00
committed by GitHub
parent c13ab00344
commit ff67c9a952
14 changed files with 203 additions and 8 deletions

View File

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

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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. */

View File

@@ -149,6 +149,7 @@ export interface LibraryBlockMetadata {
lastDraftCreatedBy: string | null,
hasUnpublishedChanges: boolean;
created: string | null,
modified: string | null,
tagsCount: number;
}

View File

@@ -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) });
}

View File

@@ -0,0 +1,6 @@
.history-widget-bar {
border-left: 8px solid $info-300;
border-radius: 4px;
padding-left: 1rem;
}

View 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;

View 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;

View File

@@ -1 +1,2 @@
@import "./status-widget/StatusWidget";
@import "./history-widget/HistoryWidget";