Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve collection sidebar #1320

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
168 changes: 168 additions & 0 deletions src/library-authoring/collections/CollectionDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type MockAdapter from 'axios-mock-adapter';
import fetchMock from 'fetch-mock-jest';

import { mockContentSearchConfig, mockGetBlockTypes } 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';

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

mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();

const library = mockContentLibrary.libraryData;
const collectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit;

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

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

it('should render Collection Details', async () => {
render(<CollectionDetails library={library} collection={collectionHit} />);

// Collection Description
expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument();
const { description } = collectionHit;
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 () => {
render(<CollectionDetails library={library} collection={collectionHit} />);

const {
description: originalDescription,
blockId,
contextKey,
} = collectionHit;

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 () => {
render(<CollectionDetails library={library} collection={collectionHit} />);

const {
description: originalDescription,
blockId,
contextKey,
} = collectionHit;

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 () => {
mockGetBlockTypes('someBlocks');
render(<CollectionDetails library={library} collection={collectionHit} />);

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

[
{ blockType: 'Total', count: 3 },
{ blockType: 'Text', count: 2 },
{ 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 () => {
mockGetBlockTypes('noBlocks');
render(<CollectionDetails library={library} collection={collectionHit} />);

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 () => {
mockGetBlockTypes('moreBlocks');
render(<CollectionDetails library={library} collection={collectionHit} />);

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

[
{ blockType: 'Total', count: 36 },
{ blockType: 'Video', count: 8 },
{ blockType: 'Problem', count: 7 },
{ blockType: 'Text', count: 6 },
{ blockType: 'Other', count: 15 },
].forEach(({ blockType, count }) => {
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
});
});
});
170 changes: 170 additions & 0 deletions src/library-authoring/collections/CollectionDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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, useGetBlockTypes } 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>
);
};

interface CollectionStatsWidgetProps {
collection: CollectionHit,
}

const CollectionStatsWidget = ({ collection }: CollectionStatsWidgetProps) => {
const { data: blockTypes } = useGetBlockTypes([
`context_key = "${collection.contextKey}"`,
`collections.key = "${collection.blockId}"`,
]);

if (!blockTypes) {
return null;
}

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);
// Show the top 3 block type counts individually, and splice the remaining block types together under "Other".
const numBlockTypesShown = 3;
const otherBlocks = blockTypesArray.splice(numBlockTypesShown);
const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0);

if (totalBlocksCount === 0) {
return (
<div
className="text-center text-muted align-content-center"
style={{
height: '72px', // same height as the BlockCount component
}}
>
<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} />}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be really cool if these BlockTypeLabel messages supported plurals. cf https://formatjs.io/docs/react-intl/components/#formattedplural

Copy link
Contributor Author

@rpenido rpenido Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also have one endpoint (mock here) that returns this same data (but not regionalized). I think we should also deduplicate this.

Could we handle this in a future task?

Copy link
Contributor

@pomegranited pomegranited Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we handle this in a future task?

Yep, this can totally be in a separate task -- you might have to push back on UI/UX review of this task, because it's a "nice to have" on this label, and not that simple to implement.

But that endpoint you referenced returns the allowed block types for a given content library, which I suspect is an old use case for libraries v2, since we create libraries that support all block types (with lib.type=='COMPLEX' by default), and there's nowhere in the frontend to change that. I personally don't think we should be using a REST API to tell us what different block types are called -- the app translations are enough.

Copy link
Contributor

@bradenmacdonald bradenmacdonald Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually we can update BlockTypeLabel so that it can load and cache the translated block names for random third-party XBlocks, but it definitely shouldn't bother using a REST API for the "standard" block types. But let's not worry about that in the coming weeks.

But that endpoint you referenced returns the allowed block types for a given content library, which I suspect is an old use case for libraries v2, since we create libraries that support all block types (with lib.type=='COMPLEX' by default)

Exactly. That endpoint should be deprecated and is not the right one to use if it's not localized.

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 collection={collection} />
</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