Skip to content

Commit ae93bcf

Browse files
committed
feat: improve collection sidebar
1 parent 353ef50 commit ae93bcf

File tree

16 files changed

+422
-18
lines changed

16 files changed

+422
-18
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
2+
import { Icon, Stack } from '@openedx/paragon';
3+
import { useContext, useState } from 'react';
4+
import classNames from 'classnames';
5+
6+
import { getItemIcon } from '../../generic/block-type-utils';
7+
import { ToastContext } from '../../generic/toast-context';
8+
import { BlockTypeLabel, type CollectionHit, useSearchContext } from '../../search-manager';
9+
import type { ContentLibrary } from '../data/api';
10+
import { useUpdateCollection } from '../data/apiHooks';
11+
import HistoryWidget from '../generic/history-widget';
12+
import messages from './messages';
13+
14+
interface BlockCountProps {
15+
count: number,
16+
blockType?: string,
17+
label: React.ReactNode,
18+
className?: string,
19+
}
20+
21+
const BlockCount = ({
22+
count,
23+
blockType,
24+
label,
25+
className,
26+
}: BlockCountProps) => {
27+
const icon = blockType && getItemIcon(blockType);
28+
return (
29+
<Stack className={classNames('text-center', className)}>
30+
<span className="text-muted">{label}</span>
31+
<Stack direction="horizontal" gap={1} className="justify-content-center">
32+
{icon && <Icon src={icon} size="lg" />}
33+
<span>{count}</span>
34+
</Stack>
35+
</Stack>
36+
);
37+
};
38+
39+
const CollectionStatsWidget = () => {
40+
const {
41+
blockTypes,
42+
} = useSearchContext();
43+
44+
const blockTypesArray = Object.entries(blockTypes)
45+
.map(([blockType, count]) => ({ blockType, count }))
46+
.sort((a, b) => b.count - a.count);
47+
48+
const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0);
49+
const otherBlocks = blockTypesArray.splice(3);
50+
const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0);
51+
52+
if (totalBlocksCount === 0) {
53+
return (
54+
<div className="text-center text-muted">
55+
<FormattedMessage {...messages.detailsTabStatsNoComponents} />
56+
</div>
57+
);
58+
}
59+
60+
return (
61+
<Stack direction="horizontal" className="p-2 justify-content-between" gap={2}>
62+
<BlockCount
63+
label={<FormattedMessage {...messages.detailsTabStatsTotalComponents} />}
64+
count={totalBlocksCount}
65+
className="border-right"
66+
/>
67+
{blockTypesArray.map(({ blockType, count }) => (
68+
<BlockCount
69+
key={blockType}
70+
label={<BlockTypeLabel type={blockType} />}
71+
blockType={blockType}
72+
count={count}
73+
/>
74+
))}
75+
{otherBlocks.length > 0 && (
76+
<BlockCount
77+
label={<FormattedMessage {...messages.detailsTabStatsOtherComponents} />}
78+
count={otherBlocksCount}
79+
/>
80+
)}
81+
</Stack>
82+
);
83+
};
84+
85+
interface CollectionDetailsProps {
86+
library: ContentLibrary,
87+
collection: CollectionHit,
88+
}
89+
90+
const CollectionDetails = ({ library, collection }: CollectionDetailsProps) => {
91+
const intl = useIntl();
92+
const { showToast } = useContext(ToastContext);
93+
94+
const [description, setDescription] = useState(collection.description);
95+
96+
const updateMutation = useUpdateCollection(library.id, collection.blockId);
97+
98+
// istanbul ignore if: this should never happen
99+
if (!collection) {
100+
return null;
101+
}
102+
103+
const onSubmit = (e: React.FocusEvent<HTMLTextAreaElement>) => {
104+
const newDescription = e.target.value;
105+
if (newDescription === collection.description) {
106+
return;
107+
}
108+
updateMutation.mutateAsync({
109+
description: newDescription,
110+
}).then(() => {
111+
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
112+
}).catch(() => {
113+
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
114+
});
115+
};
116+
117+
return (
118+
<Stack gap={3}>
119+
<div>
120+
<h3 className="h5">
121+
{intl.formatMessage(messages.detailsTabDescriptionTitle)}
122+
</h3>
123+
{library.canEditLibrary ? (
124+
<textarea
125+
className="form-control"
126+
value={description}
127+
onChange={(e) => setDescription(e.target.value)}
128+
onBlur={onSubmit}
129+
/>
130+
) : collection.description}
131+
</div>
132+
<div>
133+
<h3 className="h5">
134+
{intl.formatMessage(messages.detailsTabStatsTitle)}
135+
</h3>
136+
<CollectionStatsWidget />
137+
</div>
138+
<hr className="w-100" />
139+
<div>
140+
<h3 className="h5">
141+
{intl.formatMessage(messages.detailsTabHistoryTitle)}
142+
</h3>
143+
<HistoryWidget
144+
created={collection.created ? new Date(collection.created * 1000) : null}
145+
modified={collection.modified ? new Date(collection.modified * 1000) : null}
146+
/>
147+
</div>
148+
</Stack>
149+
);
150+
};
151+
152+
export default CollectionDetails;

src/library-authoring/collections/CollectionInfo.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import {
44
Tabs,
55
} from '@openedx/paragon';
66

7+
import type { ContentLibrary } from '../data/api';
8+
import type { CollectionHit } from '../../search-manager';
79
import messages from './messages';
10+
import CollectionDetails from './CollectionDetails';
811

9-
const CollectionInfo = () => {
12+
interface CollectionInfoProps {
13+
library: ContentLibrary,
14+
collection: CollectionHit,
15+
}
16+
17+
const CollectionInfo = ({ library, collection }: CollectionInfoProps) => {
1018
const intl = useIntl();
1119

1220
return (
@@ -19,7 +27,7 @@ const CollectionInfo = () => {
1927
Manage tab placeholder
2028
</Tab>
2129
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
22-
Details tab placeholder
30+
<CollectionDetails library={library} collection={collection} />
2331
</Tab>
2432
</Tabs>
2533
);
Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,94 @@
1-
import { type CollectionHit } from '../../search-manager/data/api';
1+
import React, { useState, useContext, useCallback } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import {
4+
Icon,
5+
IconButton,
6+
Stack,
7+
Form,
8+
} from '@openedx/paragon';
9+
import { Edit } from '@openedx/paragon/icons';
10+
11+
import { ToastContext } from '../../generic/toast-context';
12+
import type { ContentLibrary } from '../data/api';
13+
import type { CollectionHit } from '../../search-manager/data/api';
14+
import { useUpdateCollection } from '../data/apiHooks';
15+
import messages from './messages';
216

317
interface CollectionInfoHeaderProps {
4-
collection?: CollectionHit;
18+
library: ContentLibrary;
19+
collection: CollectionHit;
520
}
621

7-
const CollectionInfoHeader = ({ collection } : CollectionInfoHeaderProps) => (
8-
<div className="d-flex flex-wrap">
9-
{collection?.displayName}
10-
</div>
11-
);
22+
const CollectionInfoHeader = ({ library, collection } : CollectionInfoHeaderProps) => {
23+
const intl = useIntl();
24+
const [inputIsActive, setIsActive] = useState(false);
25+
26+
const updateMutation = useUpdateCollection(library.id, collection.blockId);
27+
const { showToast } = useContext(ToastContext);
28+
29+
const handleSaveDisplayName = useCallback(
30+
(event) => {
31+
const newTitle = event.target.value;
32+
if (newTitle && newTitle !== collection?.displayName) {
33+
updateMutation.mutateAsync({
34+
title: newTitle,
35+
}).then(() => {
36+
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
37+
}).catch(() => {
38+
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
39+
}).finally(() => {
40+
setIsActive(false);
41+
});
42+
}
43+
},
44+
[collection, showToast, intl],
45+
);
46+
47+
const handleClick = () => {
48+
setIsActive(true);
49+
};
50+
51+
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
52+
if (event.key === 'Enter') {
53+
handleSaveDisplayName(event);
54+
} else if (event.key === 'Escape') {
55+
setIsActive(false);
56+
}
57+
};
58+
59+
return (
60+
<Stack direction="horizontal">
61+
{inputIsActive
62+
? (
63+
<Form.Control
64+
autoFocus
65+
name="title"
66+
id="title"
67+
type="text"
68+
aria-label="Title input"
69+
defaultValue={collection?.displayName}
70+
onBlur={handleSaveDisplayName}
71+
onKeyDown={handleOnKeyDown}
72+
/>
73+
)
74+
: (
75+
<>
76+
<span className="font-weight-bold m-1.5">
77+
{collection?.displayName}
78+
</span>
79+
{library.canEditLibrary && (
80+
<IconButton
81+
src={Edit}
82+
iconAs={Icon}
83+
alt={intl.formatMessage(messages.editTitleButtonAlt)}
84+
onClick={handleClick}
85+
size="inline"
86+
/>
87+
)}
88+
</>
89+
)}
90+
</Stack>
91+
);
92+
};
1293

1394
export default CollectionInfoHeader;

src/library-authoring/collections/LibraryCollectionPage.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe('<LibraryCollectionPage />', () => {
127127
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
128128
expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
129129

130-
expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
130+
expect(screen.getAllByText('This collection is currently empty.')[0]).toBeInTheDocument();
131131

132132
const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1];
133133
fireEvent.click(addComponentButton);
@@ -150,7 +150,7 @@ describe('<LibraryCollectionPage />', () => {
150150
await renderLibraryCollectionPage(mockCollection.collectionNoComponents, libraryId);
151151

152152
expect(await screen.findByText('All Collections')).toBeInTheDocument();
153-
expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
153+
expect(screen.getAllByText('This collection is currently empty.')[0]).toBeInTheDocument();
154154
expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument();
155155
expect(screen.getByText('Read Only')).toBeInTheDocument();
156156
});

src/library-authoring/collections/messages.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,41 @@ const messages = defineMessages({
1111
defaultMessage: 'Details',
1212
description: 'Title for details tab',
1313
},
14+
detailsTabDescriptionTitle: {
15+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-title',
16+
defaultMessage: 'Description / Card Preview Text',
17+
description: 'Title for the Description container in the details tab',
18+
},
19+
detailsTabDescriptionPlaceholder: {
20+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-placeholder',
21+
defaultMessage: 'Add description',
22+
description: 'Placeholder for the Description container in the details tab',
23+
},
24+
detailsTabStatsTitle: {
25+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-title',
26+
defaultMessage: 'Collection Stats',
27+
description: 'Title for the Collection Stats container in the details tab',
28+
},
29+
detailsTabStatsNoComponents: {
30+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-no-components',
31+
defaultMessage: 'This collection is currently empty.',
32+
description: 'Message displayed when no components are found in the Collection Stats container',
33+
},
34+
detailsTabStatsTotalComponents: {
35+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-total-components',
36+
defaultMessage: 'Total ',
37+
description: 'Label for total components in the Collection Stats container',
38+
},
39+
detailsTabStatsOtherComponents: {
40+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-other-components',
41+
defaultMessage: 'Other',
42+
description: 'Label for other components in the Collection Stats container',
43+
},
44+
detailsTabHistoryTitle: {
45+
id: 'course-authoring.library-authoring.collections-sidebar.details-tab.history-title',
46+
defaultMessage: 'Collection History',
47+
description: 'Title for the Collection History container in the details tab',
48+
},
1449
noComponentsInCollection: {
1550
id: 'course-authoring.library-authoring.collections-pag.no-components.text',
1651
defaultMessage: 'This collection is currently empty.',
@@ -71,6 +106,21 @@ const messages = defineMessages({
71106
defaultMessage: 'Add collection',
72107
description: 'Button text to add a new collection',
73108
},
109+
updateCollectionSuccessMsg: {
110+
id: 'course-authoring.library-authoring.update-collection-success-msg',
111+
defaultMessage: 'Collection updated successfully.',
112+
description: 'Message displayed when collection is updated successfully',
113+
},
114+
updateCollectionErrorMsg: {
115+
id: 'course-authoring.library-authoring.update-collection-error-msg',
116+
defaultMessage: 'Failed to update collection.',
117+
description: 'Message displayed when collection update fails',
118+
},
119+
editTitleButtonAlt: {
120+
id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt',
121+
defaultMessage: 'Edit collection title',
122+
description: 'Alt text for edit collection title icon button',
123+
},
74124
});
75125

76126
export default messages;

src/library-authoring/components/CollectionCard.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const CollectionHitSample: CollectionHit = {
77
id: '1',
88
type: 'collection',
99
contextKey: 'lb:org1:Demo_Course',
10+
usageKey: 'lb:org1:Demo_Course:collection1',
11+
blockId: 'collection1',
1012
org: 'org1',
1113
breadcrumbs: [{ displayName: 'Demo Lib' }],
1214
displayName: 'Collection Display Name',

0 commit comments

Comments
 (0)