Skip to content

Commit

Permalink
feat: improve collection sidebar
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Sep 25, 2024
1 parent ff67c9a commit f9fa28e
Show file tree
Hide file tree
Showing 21 changed files with 828 additions and 77 deletions.
2 changes: 1 addition & 1 deletion src/library-authoring/__mocks__/collection-search.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
}
],
"created": 1726740779.564664,
"modified": 1726740811.684142,
"modified": 1726840811.684142,
"usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
Expand Down
201 changes: 201 additions & 0 deletions src/library-authoring/collections/CollectionDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import type MockAdapter from 'axios-mock-adapter';
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';

import { SearchContextProvider } from '../../search-manager';
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import { type CollectionHit, formatSearchHit } from '../../search-manager/data/api';
import {
initializeMocks,
fireEvent,
render,
screen,
waitFor,
within,
} from '../../testUtils';
import mockResult from '../__mocks__/collection-search.json';
import * as api from '../data/api';
import { mockContentLibrary } from '../data/api.mocks';
import CollectionDetails from './CollectionDetails';

const searchEndpoint = 'http://mock.meilisearch.local/multi-search';

let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;

mockContentSearchConfig.applyMock();
const library = mockContentLibrary.libraryData;

describe('<CollectionDetails />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
});

afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
fetchMock.mockReset();
});

const renderCollectionDetails = async () => {
const collectionData: CollectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit;

render((
<SearchContextProvider>
<CollectionDetails library={library} collection={collectionData} />
</SearchContextProvider>
));

await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
};

it('should render Collection Details', async () => {
mockSearchResult(mockResult);
await renderCollectionDetails();

// Collection Description
expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument();
const { description } = mockResult.results[2].hits[0];
expect(screen.getByText(description)).toBeInTheDocument();

// Collection History
expect(screen.getByText('Collection History')).toBeInTheDocument();
// Modified date
expect(screen.getByText('September 20, 2024')).toBeInTheDocument();
// Created date
expect(screen.getByText('September 19, 2024')).toBeInTheDocument();
});

it('should allow modifying the description', async () => {
mockSearchResult(mockResult);
await renderCollectionDetails();

const {
description: originalDescription,
block_id: blockId,
context_key: contextKey,
} = mockResult.results[2].hits[0];

expect(screen.getByText(originalDescription)).toBeInTheDocument();

const url = api.getLibraryCollectionApiUrl(contextKey, blockId);
axiosMock.onPatch(url).reply(200);

const textArea = screen.getByRole('textbox');

// Change the description to the same value
fireEvent.focus(textArea);
fireEvent.change(textArea, { target: { value: originalDescription } });
fireEvent.blur(textArea);

await waitFor(() => {
expect(axiosMock.history.patch).toHaveLength(0);
expect(mockShowToast).not.toHaveBeenCalled();
});

// Change the description to a new value
fireEvent.focus(textArea);
fireEvent.change(textArea, { target: { value: 'New description' } });
fireEvent.blur(textArea);

await waitFor(() => {
expect(axiosMock.history.patch).toHaveLength(1);
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' }));
expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.');
});
});

it('should show error while modifing the description', async () => {
mockSearchResult(mockResult);
await renderCollectionDetails();

const {
description: originalDescription,
block_id: blockId,
context_key: contextKey,
} = mockResult.results[2].hits[0];

expect(screen.getByText(originalDescription)).toBeInTheDocument();

const url = api.getLibraryCollectionApiUrl(contextKey, blockId);
axiosMock.onPatch(url).reply(500);

const textArea = screen.getByRole('textbox');

// Change the description to a new value
fireEvent.focus(textArea);
fireEvent.change(textArea, { target: { value: 'New description' } });
fireEvent.blur(textArea);

await waitFor(() => {
expect(axiosMock.history.patch).toHaveLength(1);
expect(axiosMock.history.patch[0].url).toEqual(url);
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' }));
expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.');
});
});

it('should render Collection stats', async () => {
mockSearchResult(mockResult);
await renderCollectionDetails();

expect(screen.getByText('Collection Stats')).toBeInTheDocument();
expect(await screen.findByText('Total')).toBeInTheDocument();

[
{ blockType: 'Total', count: 5 },
{ blockType: 'Text', count: 4 },
{ blockType: 'Problem', count: 1 },
].forEach(({ blockType, count }) => {
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
});
});

it('should render Collection stats for empty collection', async () => {
const mockResultCopy = cloneDeep(mockResult);
mockResultCopy.results[1].facetDistribution.block_type = {};
mockSearchResult(mockResultCopy);
await renderCollectionDetails();

expect(screen.getByText('Collection Stats')).toBeInTheDocument();
expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument();
});

it('should render Collection stats for big collection', async () => {
const mockResultCopy = cloneDeep(mockResult);
mockResultCopy.results[1].facetDistribution.block_type = {
annotatable: 1,
chapter: 2,
discussion: 3,
drag_and_drop_v2: 4,
html: 5,
library_content: 6,
openassessment: 7,
problem: 8,
sequential: 9,
vertical: 10,
video: 11,
choiceresponse: 12,
};
mockSearchResult(mockResultCopy);
await renderCollectionDetails();

expect(screen.getByText('Collection Stats')).toBeInTheDocument();
expect(await screen.findByText('78')).toBeInTheDocument();

[
{ blockType: 'Total', count: 78 },
{ blockType: 'Multiple Choice', count: 12 },
{ blockType: 'Video', count: 11 },
{ blockType: 'Unit', count: 10 },
{ blockType: 'Other', count: 45 },
].forEach(({ blockType, count }) => {
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
});
});
});
154 changes: 154 additions & 0 deletions src/library-authoring/collections/CollectionDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Stack } from '@openedx/paragon';
import { useContext, useState } from 'react';
import classNames from 'classnames';

import { getItemIcon } from '../../generic/block-type-utils';
import { ToastContext } from '../../generic/toast-context';
import { BlockTypeLabel, type CollectionHit, useSearchContext } from '../../search-manager';
import type { ContentLibrary } from '../data/api';
import { useUpdateCollection } from '../data/apiHooks';
import HistoryWidget from '../generic/history-widget';
import messages from './messages';

interface BlockCountProps {
count: number,
blockType?: string,
label: React.ReactNode,
className?: string,
}

const BlockCount = ({
count,
blockType,
label,
className,
}: BlockCountProps) => {
const icon = blockType && getItemIcon(blockType);
return (
<Stack className={classNames('text-center', className)}>
<span className="text-muted">{label}</span>
<Stack direction="horizontal" gap={1} className="justify-content-center">
{icon && <Icon src={icon} size="lg" />}
<span>{count}</span>
</Stack>
</Stack>
);
};

const CollectionStatsWidget = () => {
const {
blockTypes,
} = useSearchContext();

const blockTypesArray = Object.entries(blockTypes)
.map(([blockType, count]) => ({ blockType, count }))
.sort((a, b) => b.count - a.count);

const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0);
const otherBlocks = blockTypesArray.splice(3);
const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0);

if (totalBlocksCount === 0) {
return (
<div className="text-center text-muted">
<FormattedMessage {...messages.detailsTabStatsNoComponents} />
</div>
);
}

return (
<Stack direction="horizontal" className="p-2 justify-content-between" gap={2}>
<BlockCount
label={<FormattedMessage {...messages.detailsTabStatsTotalComponents} />}
count={totalBlocksCount}
className="border-right"
/>
{blockTypesArray.map(({ blockType, count }) => (
<BlockCount
key={blockType}
label={<BlockTypeLabel type={blockType} />}
blockType={blockType}
count={count}
/>
))}
{otherBlocks.length > 0 && (
<BlockCount
label={<FormattedMessage {...messages.detailsTabStatsOtherComponents} />}
count={otherBlocksCount}
/>
)}
</Stack>
);
};

interface CollectionDetailsProps {
library: ContentLibrary,
collection: CollectionHit,
}

const CollectionDetails = ({ library, collection }: CollectionDetailsProps) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);

const [description, setDescription] = useState(collection.description);

const updateMutation = useUpdateCollection(collection.contextKey, collection.blockId);

// istanbul ignore if: this should never happen
if (!collection) {
throw new Error('A collection must be provided to CollectionDetails');
}

const onSubmit = (e: React.FocusEvent<HTMLTextAreaElement>) => {
const newDescription = e.target.value;
if (newDescription === collection.description) {
return;
}
updateMutation.mutateAsync({
description: newDescription,
}).then(() => {
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
});
};

return (
<Stack
gap={3}
>
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabDescriptionTitle)}
</h3>
{library.canEditLibrary ? (
<textarea
className="form-control"
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={onSubmit}
/>
) : collection.description}
</div>
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabStatsTitle)}
</h3>
<CollectionStatsWidget />
</div>
<hr className="w-100" />
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabHistoryTitle)}
</h3>
<HistoryWidget
created={collection.created ? new Date(collection.created * 1000) : null}
modified={collection.modified ? new Date(collection.modified * 1000) : null}
/>
</div>
</Stack>
);
};

export default CollectionDetails;
Loading

0 comments on commit f9fa28e

Please sign in to comment.