Skip to content

Commit 3cf3870

Browse files
feat: having a slot per tab
1 parent 73efa2d commit 3cf3870

File tree

8 files changed

+160
-47
lines changed

8 files changed

+160
-47
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"*.scss"
1919
],
2020
"scripts": {
21-
"dev": "PORT=8080 PUBLIC_PATH=/instructor openedx dev",
21+
"dev": "PORT=8081 PUBLIC_PATH=/instructor openedx dev",
2222
"i18n_extract": "openedx formatjs extract",
2323
"lint": "openedx lint .",
2424
"lint:fix": "openedx lint --fix .",

src/app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { App } from '@openedx/frontend-base';
22
import { appId } from './constants';
33
import routes from './routes';
44
import messages from './i18n';
5+
import slots from './slots';
56

67
const app: App = {
78
appId,
89
routes,
910
messages,
1011
providers: [],
11-
slots: [],
12+
slots,
1213
config: {}
1314
};
1415

src/instructorTabs/InstructorTabs.tsx

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,58 @@
1-
import { useState, useEffect, useCallback } from 'react';
1+
import { useState, useEffect, useCallback, useContext } from 'react';
22
import { useNavigate, useLocation } from 'react-router-dom';
3-
import { Tabs, Tab } from '@openedx/paragon';
3+
import { Tab, Tabs } from '@openedx/paragon';
4+
import { SlotContext, BaseSlotOperation, useSlotOperations } from '@openedx/frontend-base';
45

5-
enum InstructorTabKeys {
6-
COURSE_INFO = 'courseInfo',
7-
ENROLLMENTS = 'enrollments',
8-
COURSE_TEAM = 'courseTeam',
9-
GRADING = 'grading',
10-
DATE_EXTENSIONS = 'dateExtensions',
11-
DATA_DOWNLOADS = 'dataDownloads',
12-
OPEN_RESPONSES = 'openResponses',
13-
CERTIFICATES = 'certificates',
14-
COHORTS = 'cohorts',
15-
SPECIAL_EXAMS = 'specialExams',
16-
}
17-
18-
interface TabConfig {
19-
tab_id: InstructorTabKeys,
6+
export interface TabProps {
7+
tab_id: string,
208
url: string,
219
title: string,
2210
}
2311

24-
// example of tabs response from an API, should be refactored to react query when backend is ready
25-
const tabs: TabConfig[] = [
26-
{ tab_id: InstructorTabKeys.COURSE_INFO, url: 'course_info', title: 'Course Info' },
27-
{ tab_id: InstructorTabKeys.ENROLLMENTS, url: 'enrollments', title: 'Enrollments' },
28-
{ tab_id: InstructorTabKeys.COURSE_TEAM, url: 'course_team', title: 'Course Team' },
29-
{ tab_id: InstructorTabKeys.GRADING, url: 'grading', title: 'Grading' },
30-
{ tab_id: InstructorTabKeys.DATE_EXTENSIONS, url: 'date_extensions', title: 'Date Extensions' },
31-
{ tab_id: InstructorTabKeys.DATA_DOWNLOADS, url: 'data_downloads', title: 'Data Downloads' },
32-
{ tab_id: InstructorTabKeys.OPEN_RESPONSES, url: 'open_responses', title: 'Open Responses' },
33-
{ tab_id: InstructorTabKeys.CERTIFICATES, url: 'certificates', title: 'Certificates' },
34-
{ tab_id: InstructorTabKeys.COHORTS, url: 'cohorts', title: 'Cohorts' },
35-
{ tab_id: InstructorTabKeys.SPECIAL_EXAMS, url: 'special_exams', title: 'Special Exams' },
36-
];
12+
interface SlotWithElementOperation extends BaseSlotOperation {
13+
element: React.ReactElement,
14+
}
3715

3816
const InstructorTabs = () => {
3917
const navigate = useNavigate();
4018
const location = useLocation();
41-
const getActiveTabFromUrl = useCallback((): InstructorTabKeys => {
42-
const currentPath = location.pathname.split('/').pop() ?? '';
43-
const activeTab = tabs.find(({ url }) => url === currentPath);
44-
return (activeTab ? activeTab.tab_id : InstructorTabKeys.COURSE_INFO) as InstructorTabKeys;
45-
}, [location.pathname]);
19+
const { id: slotId } = useContext(SlotContext);
20+
const widgets: SlotWithElementOperation[] = useSlotOperations(slotId) as SlotWithElementOperation[];
4621

47-
const [tabKey, setTabKey] = useState<InstructorTabKeys>(getActiveTabFromUrl);
22+
const [tabKey, setTabKey] = useState<string>('courseInfo');
23+
24+
const getActiveTabFromUrl = useCallback(() => {
25+
const currentPath = location.pathname.split('/').pop() ?? '';
26+
const activeTab = widgets.find((slot) => slot.element.props.url === currentPath)?.element;
27+
return activeTab ? activeTab.props.tab_id : '';
28+
}, [widgets, location.pathname]);
4829

4930
useEffect(() => {
5031
setTabKey(getActiveTabFromUrl());
5132
}, [getActiveTabFromUrl]);
5233

5334
const handleSelect = (eventKey: string | null) => {
5435
if (eventKey) {
55-
const tabKey = eventKey as InstructorTabKeys;
56-
const selectedUrl = tabs.find(tab => tab.tab_id === tabKey)?.url;
36+
const tabKey = eventKey;
37+
const selectedElement = widgets.find((slot) => slot?.element?.props.tab_id === tabKey)?.element;
38+
const selectedUrl = selectedElement?.props.url;
5739
setTabKey(tabKey);
5840
if (selectedUrl) {
5941
navigate(`/${selectedUrl}`);
6042
}
6143
}
6244
};
6345

46+
if (widgets.length === 0) return null;
47+
6448
return (
6549
<Tabs id="instructor-tabs" activeKey={tabKey} onSelect={handleSelect}>
66-
{tabs.map(({ tab_id, title }) => (
67-
<Tab key={tab_id} eventKey={tab_id} title={title} />
68-
))}
50+
{widgets.map((widget: any, index: number) => {
51+
// We get props from TabSlot to create each Tab
52+
const element = widget.element;
53+
const { tab_id, title } = element.props;
54+
return <Tab key={tab_id ?? index} eventKey={tab_id} title={title} />;
55+
})}
6956
</Tabs>
7057
);
7158
};

src/instructorTabs/app.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { SlotOperation, WidgetOperationTypes } from '@openedx/frontend-base';
2+
import TabSlot from '../slots/instructorTabsSlot/TabSlot';
3+
import { TabProps } from './InstructorTabs';
4+
5+
enum InstructorTabKeys {
6+
COURSE_INFO = 'courseInfo',
7+
ENROLLMENTS = 'enrollments',
8+
COURSE_TEAM = 'courseTeam',
9+
GRADING = 'grading',
10+
DATE_EXTENSIONS = 'dateExtensions',
11+
DATA_DOWNLOADS = 'dataDownloads',
12+
OPEN_RESPONSES = 'openResponses',
13+
CERTIFICATES = 'certificates',
14+
COHORTS = 'cohorts',
15+
SPECIAL_EXAMS = 'specialExams',
16+
}
17+
18+
// example of tabs response from an API, should be refactored to react query when backend is ready
19+
const tabData: TabProps[] = [
20+
{ tab_id: InstructorTabKeys.COURSE_INFO, url: 'course_info', title: 'Course Info' },
21+
{ tab_id: InstructorTabKeys.ENROLLMENTS, url: 'enrollments', title: 'Enrollments' },
22+
{ tab_id: InstructorTabKeys.COURSE_TEAM, url: 'course_team', title: 'Course Team' },
23+
{ tab_id: InstructorTabKeys.GRADING, url: 'grading', title: 'Grading' },
24+
{ tab_id: InstructorTabKeys.DATE_EXTENSIONS, url: 'date_extensions', title: 'Date Extensions' },
25+
{ tab_id: InstructorTabKeys.DATA_DOWNLOADS, url: 'data_downloads', title: 'Data Downloads' },
26+
{ tab_id: InstructorTabKeys.OPEN_RESPONSES, url: 'open_responses', title: 'Open Responses' },
27+
{ tab_id: InstructorTabKeys.CERTIFICATES, url: 'certificates', title: 'Certificates' },
28+
{ tab_id: InstructorTabKeys.COHORTS, url: 'cohorts', title: 'Cohorts' },
29+
{ tab_id: InstructorTabKeys.SPECIAL_EXAMS, url: 'special_exams', title: 'Special Exams' },
30+
];
31+
32+
export const tabSlots: SlotOperation[] = tabData.map(({ tab_id, title, url }: TabProps) => ({
33+
slotId: `org.openedx.frontend.slot.instructor.tabs.v1`,
34+
id: `org.openedx.frontend.widget.instructor.tab.${tab_id}`,
35+
op: WidgetOperationTypes.APPEND,
36+
element: <TabSlot tab_id={tab_id} title={title} url={url} />,
37+
}));

src/slots.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { SlotOperation } from '@openedx/frontend-base';
2+
import { tabSlots } from './instructorTabs/app';
3+
4+
const slots: SlotOperation[] = [
5+
...tabSlots ?? [],
6+
];
7+
8+
export default slots;

src/slots/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,71 @@
11
# `frontend-app-instruct` Slots
2+
3+
## Overview
4+
5+
Slots in `frontend-app-instruct` use the slot system from `@openedx/frontend-base` to provide modular extension points in the application. This system allows different widgets to be dynamically registered at specific UI locations.
6+
7+
## Slot Architecture
8+
9+
### Main Components
10+
11+
1. **Slot Operations**: Operation definitions that specify how and where widgets will be inserted
12+
2. **Slot Components**: React components that act as containers for widgets
13+
3. **Widget Components**: Individual components that are inserted into slots
14+
15+
## Instructor Tabs Slot
16+
17+
### 1. Slot Operations Definition
18+
19+
The `src/instructorTabs/app.tsx` file shows how to define slot operations:
20+
21+
```tsx
22+
import { SlotOperation, WidgetOperationTypes } from '@openedx/frontend-base';
23+
import TabSlot from '../slots/instructorTabsSlot/TabSlot';
24+
25+
// Tab configuration data
26+
const tabData = { tab_id: 'course_info', url: 'course_info', title: 'Course Info' };
27+
28+
// Create slot operations
29+
export const tabSlots: SlotOperation[] = [{
30+
slotId: `org.openedx.frontend.slot.instructor.tabs.v1`,
31+
id: `org.openedx.frontend.widget.instructor.tab.${tab_id}`,
32+
op: WidgetOperationTypes.APPEND,
33+
element: <TabSlot tab_id={tabData.tab_id} title={tabData.title} url={tabData.url} />,
34+
}];
35+
```
36+
37+
### 2. Slot Element
38+
39+
The `TabSlot` component acts as a placeholder that maintains the necessary props:
40+
41+
```tsx
42+
import { TabProps } from '../../instructorTabs/InstructorTabs';
43+
44+
const TabSlot = (_props: TabProps) => {
45+
return null; // Placeholder component
46+
};
47+
48+
export default TabSlot;
49+
```
50+
51+
### 3. Slot Consumer
52+
53+
The `InstructorTabs` component consumes the registered slots:
54+
55+
```tsx
56+
import { SlotContext, useSlotOperations } from '@openedx/frontend-base';
57+
58+
const InstructorTabs = () => {
59+
const { id: slotId } = useContext(SlotContext);
60+
const widgets = useSlotOperations(slotId);
61+
62+
return (
63+
<Tabs>
64+
{widgets.map((widget, index) => {
65+
const { tab_id, title } = widget.element.props;
66+
return <Tab key={tab_id} eventKey={tab_id} title={title} />;
67+
})}
68+
</Tabs>
69+
);
70+
};
71+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
import { TabProps } from '../../instructorTabs/InstructorTabs';
3+
4+
// This component will be a placeholder/dummy component just to retrieve Tab props
5+
// Since we are using a slot-based architecture and Paragon is passing Tabs/Tab through
6+
// We can't have context provider between Tabs and Tab when rendering it should be direct parent/children relation
7+
8+
const TabSlot = (_props: TabProps) => {
9+
return null;
10+
};
11+
12+
export default TabSlot;

src/slots/instructorTabsSlot/instructorTabsSlot.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { Slot } from '@openedx/frontend-base';
2-
import { InstructorTabs } from '../../instructorTabs';
2+
import InstructorTabs from '../../instructorTabs/InstructorTabs';
33

44
export const InstructorTabsSlot = () => (
5-
<Slot
6-
id="org.openedx.frontend.slot.instructor.tabs.v1"
7-
>
5+
<Slot id="org.openedx.frontend.slot.instructor.tabs.v1">
86
<InstructorTabs />
97
</Slot>
108
);

0 commit comments

Comments
 (0)