Skip to content

Commit 242f403

Browse files
committed
feat: collections bare bones page
1 parent a37a1b1 commit 242f403

File tree

11 files changed

+314
-37
lines changed

11 files changed

+314
-37
lines changed

src/library-authoring/LibraryAuthoringPage.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@
2020
height: 100vh;
2121
overflow-y: auto;
2222
}
23+
24+
// Reduce breadcrumb bottom margin
25+
ol.list-inline {
26+
margin-bottom: 0rem;
27+
}

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ const LibraryAuthoringPage = () => {
166166
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
167167
<SearchContextProvider
168168
extraFilter={`context_key = "${libraryId}"`}
169+
fetchCollections={true}
169170
>
170171
<SubHeader
171172
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import React, { useContext } from 'react';
2+
import classNames from 'classnames';
3+
import { StudioFooter } from '@edx/frontend-component-footer';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import {
6+
Badge,
7+
Button,
8+
Breadcrumb,
9+
Container,
10+
Icon,
11+
IconButton,
12+
Stack,
13+
} from '@openedx/paragon';
14+
import { Add, InfoOutline } from '@openedx/paragon/icons';
15+
import { Link, useParams } from 'react-router-dom';
16+
17+
import Loading from '../generic/Loading';
18+
import SubHeader from '../generic/sub-header/SubHeader';
19+
import Header from '../header';
20+
import NotFoundAlert from '../generic/NotFoundAlert';
21+
import {
22+
ClearFiltersButton,
23+
FilterByBlockType,
24+
FilterByTags,
25+
SearchContextProvider,
26+
SearchKeywordsField,
27+
SearchSortWidget,
28+
} from '../search-manager';
29+
import { useCollection, useContentLibrary } from './data/apiHooks';
30+
import { LibraryContext, SidebarBodyComponentId } from './common/context';
31+
import messages from './messages';
32+
33+
interface HeaderActionsProps {
34+
canEditLibrary: boolean;
35+
}
36+
37+
const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => {
38+
const intl = useIntl();
39+
const {
40+
openAddContentSidebar,
41+
openInfoSidebar,
42+
closeLibrarySidebar,
43+
sidebarBodyComponent,
44+
} = useContext(LibraryContext);
45+
46+
if (!canEditLibrary) {
47+
return null;
48+
}
49+
50+
const infoSidebarIsOpen = () => (
51+
sidebarBodyComponent === SidebarBodyComponentId.Info
52+
);
53+
54+
const handleOnClickInfoSidebar = () => {
55+
if (infoSidebarIsOpen()) {
56+
closeLibrarySidebar();
57+
} else {
58+
openInfoSidebar();
59+
}
60+
};
61+
62+
return (
63+
<div className="header-actions">
64+
<Button
65+
className={classNames('mr-1', {
66+
'normal-border': !infoSidebarIsOpen(),
67+
'open-border': infoSidebarIsOpen(),
68+
})}
69+
iconBefore={InfoOutline}
70+
variant="outline-primary rounded-0"
71+
onClick={handleOnClickInfoSidebar}
72+
>
73+
{intl.formatMessage(messages.libraryInfoButton)}
74+
</Button>
75+
<Button
76+
className="ml-1"
77+
iconBefore={Add}
78+
variant="primary rounded-0"
79+
onClick={openAddContentSidebar}
80+
disabled={!canEditLibrary}
81+
>
82+
{intl.formatMessage(messages.newContentButton)}
83+
</Button>
84+
</div>
85+
);
86+
};
87+
88+
const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => {
89+
const intl = useIntl();
90+
91+
return (
92+
<Stack direction="vertical">
93+
<Stack direction="horizontal" gap={2}>
94+
{title}
95+
<IconButton
96+
src={InfoOutline}
97+
iconAs={Icon}
98+
alt={intl.formatMessage(messages.collectionInfoButton)}
99+
onClick={() => {}}
100+
variant="primary"
101+
/>
102+
</Stack>
103+
{ !canEditLibrary && (
104+
<div>
105+
<Badge variant="primary" style={{ fontSize: '50%' }}>
106+
{intl.formatMessage(messages.readOnlyBadge)}
107+
</Badge>
108+
</div>
109+
)}
110+
</Stack>
111+
);
112+
};
113+
114+
const LibraryCollectionPage = ({ libraryId, collectionId }: { libraryId: string, collectionId: string }) => {
115+
const intl = useIntl();
116+
117+
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
118+
const { data: collectionData, isLoading: isCollectionLoading } = useCollection(libraryId, collectionId);
119+
120+
if (isLibLoading || isCollectionLoading) {
121+
return <Loading />;
122+
}
123+
124+
if (!libraryData || !collectionData) {
125+
return <NotFoundAlert />;
126+
}
127+
128+
const breadcrumbs = [
129+
{
130+
label: libraryData.title,
131+
to: `/library/${libraryId}`,
132+
},
133+
{
134+
label: intl.formatMessage(messages.allCollections),
135+
to: `/library/${libraryId}/collections`,
136+
},
137+
// Adding empty breadcrumb to add the last `>` spacer.
138+
{
139+
label: '',
140+
to: ``,
141+
},
142+
];
143+
144+
return (
145+
<div className="d-flex overflow-auto">
146+
<div className="flex-grow-1 align-content-center">
147+
<Header
148+
number={libraryData.slug}
149+
title={libraryData.title}
150+
org={libraryData.org}
151+
contextId={libraryId}
152+
isLibrary
153+
/>
154+
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
155+
<SubHeader
156+
title={<SubHeaderTitle title={collectionData.title} canEditLibrary={libraryData.canEditLibrary} />}
157+
breadcrumbs={(
158+
<Breadcrumb
159+
ariaLabel={intl.formatMessage(messages.allCollections)}
160+
links={breadcrumbs}
161+
linkAs={Link}
162+
/>
163+
)}
164+
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
165+
/>
166+
<SearchKeywordsField className="w-50" />
167+
<div className="d-flex mt-3 align-items-center">
168+
<FilterByTags />
169+
<FilterByBlockType />
170+
<ClearFiltersButton />
171+
<div className="flex-grow-1" />
172+
<SearchSortWidget />
173+
</div>
174+
</Container>
175+
<StudioFooter />
176+
</div>
177+
</div>
178+
);
179+
};
180+
181+
const LibraryCollectionPageWrapper = () => {
182+
const { libraryId, collectionId } = useParams();
183+
if (!collectionId || !libraryId) {
184+
throw new Error('Rendered without collectionId or libraryId URL parameter');
185+
}
186+
187+
return (
188+
<SearchContextProvider
189+
extraFilter={`context_key = "${libraryId}"`}
190+
>
191+
<LibraryCollectionPage libraryId={libraryId} collectionId={collectionId} />
192+
</SearchContextProvider>
193+
);
194+
}
195+
196+
export default LibraryCollectionPageWrapper;

src/library-authoring/LibraryLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage';
1313
import { LibraryProvider } from './common/context';
1414
import { CreateCollectionModal } from './create-collection';
1515
import { invalidateComponentData } from './data/apiHooks';
16+
import LibraryCollectionPageWrapper from './LibraryCollectionPage';
1617

1718
const LibraryLayout = () => {
1819
const { libraryId } = useParams();
@@ -45,6 +46,10 @@ const LibraryLayout = () => {
4546
</PageWrap>
4647
)}
4748
/>
49+
<Route
50+
path="collections/:collectionId"
51+
element={<LibraryCollectionPageWrapper />}
52+
/>
4853
<Route
4954
path="*"
5055
element={<LibraryAuthoringPage />}

src/library-authoring/LibraryRecentlyModified.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId })
8181
<SearchContextProvider
8282
extraFilter={`context_key = "${libraryId}"`}
8383
overrideSearchSortOrder={SearchSortOption.RECENTLY_MODIFIED}
84+
fetchCollections={true}
8485
>
8586
<RecentlyModified libraryId={libraryId} />
8687
</SearchContextProvider>

src/library-authoring/data/api.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/
3636
* Get the URL for the Library Collections API.
3737
*/
3838
export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`;
39+
/**
40+
* Get the URL for the collection API.
41+
*/
42+
export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`;
3943

4044
export interface ContentLibrary {
4145
id: string;
@@ -61,6 +65,20 @@ export interface ContentLibrary {
6165
updated: string | null;
6266
}
6367

68+
export interface Collection {
69+
id: number;
70+
key: string;
71+
title: string;
72+
description: string;
73+
enabled: boolean;
74+
createdBy: string | null;
75+
created: string;
76+
modified: string;
77+
// TODO: Update the type below once entities are properly linked
78+
entities: Array<any>;
79+
learningPackage: number;
80+
}
81+
6482
export interface LibraryBlockType {
6583
blockType: string;
6684
displayName: string;
@@ -267,3 +285,12 @@ export async function getXBlockOLX(usageKey: string): Promise<string> {
267285
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey));
268286
return data.olx;
269287
}
288+
289+
/**
290+
* Fetch a collection by its ID.
291+
*/
292+
export async function getCollection(libraryId: string, collectionId: string): Promise<Collection> {
293+
const { data } = await getAuthenticatedHttpClient().get(getLibraryCollectionApiUrl(libraryId, collectionId));
294+
return camelCaseObject(data);
295+
}
296+

src/library-authoring/data/apiHooks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
createCollection,
2727
getXBlockOLX,
2828
type CreateLibraryCollectionDataRequest,
29+
getCollection,
2930
} from './api';
3031

3132
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -60,6 +61,7 @@ export const libraryAuthoringQueryKeys = {
6061
'content',
6162
'libraryBlockTypes',
6263
],
64+
collection: (collectionId?: string) => [...libraryAuthoringQueryKeys.all, collectionId],
6365
};
6466

6567
export const xblockQueryKeys = {
@@ -261,3 +263,15 @@ export const useXBlockOLX = (usageKey: string) => (
261263
enabled: !!usageKey,
262264
})
263265
);
266+
267+
/**
268+
* Hook to fetch a collection by its ID.
269+
*/
270+
export const useCollection = (libraryId: string | undefined, collectionId: string | undefined) => (
271+
useQuery({
272+
queryKey: libraryAuthoringQueryKeys.contentLibrary(collectionId),
273+
queryFn: () => getCollection(libraryId!, collectionId!),
274+
enabled: collectionId !== undefined && libraryId !== undefined,
275+
})
276+
);
277+

src/library-authoring/messages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ const messages = defineMessages({
66
defaultMessage: 'Content library',
77
description: 'The page heading for the library page.',
88
},
9+
allCollections: {
10+
id: 'course-authoring.library-authoring.all-collections',
11+
defaultMessage: 'All Collections',
12+
description: 'Breadcrumbs text to navigate back to all collections',
13+
},
914
headingInfoAlt: {
1015
id: 'course-authoring.library-authoring.heading-info-alt',
1116
defaultMessage: 'Info',
@@ -116,6 +121,11 @@ const messages = defineMessages({
116121
defaultMessage: 'Library Info',
117122
description: 'Text of button to open "Library Info sidebar"',
118123
},
124+
collectionInfoButton: {
125+
id: 'course-authoring.library-authoring.buttons.collection-info.alt-text',
126+
defaultMessage: 'Collection Info',
127+
description: 'Alt text for collection info button besides the collection title',
128+
},
119129
readOnlyBadge: {
120130
id: 'course-authoring.library-authoring.badge.read-only',
121131
defaultMessage: 'Read Only',

src/search-manager/SearchManager.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ export const SearchContextProvider: React.FC<{
9191
overrideSearchSortOrder?: SearchSortOption
9292
children: React.ReactNode,
9393
closeSearchModal?: () => void,
94-
}> = ({ overrideSearchSortOrder, ...props }) => {
94+
fetchCollections?: boolean,
95+
}> = ({ overrideSearchSortOrder, fetchCollections, ...props }) => {
9596
const [searchKeywords, setSearchKeywords] = React.useState('');
9697
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
9798
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
@@ -130,14 +131,7 @@ export const SearchContextProvider: React.FC<{
130131
}, []);
131132

132133
// Initialize a connection to Meilisearch:
133-
const { data: connectionDetails, isError: hasConnectionError } = useContentSearchConnection();
134-
const indexName = connectionDetails?.indexName;
135-
const client = React.useMemo(() => {
136-
if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) {
137-
return undefined;
138-
}
139-
return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey });
140-
}, [connectionDetails?.apiKey, connectionDetails?.url]);
134+
const { client, indexName, hasConnectionError } = useContentSearchConnection();
141135

142136
// Run the search
143137
const result = useContentSearchResults({
@@ -149,6 +143,7 @@ export const SearchContextProvider: React.FC<{
149143
problemTypesFilter,
150144
tagsFilter,
151145
sort,
146+
fetchCollections,
152147
});
153148

154149
return React.createElement(SearchContext.Provider, {

0 commit comments

Comments
 (0)