Skip to content

Commit fc9f29c

Browse files
authored
Merge pull request #89 from iblai/feat/authoring-tab-on-course-pages
2 parents f0299d9 + 0d66ddf commit fc9f29c

20 files changed

Lines changed: 443 additions & 35 deletions

File tree

app/course-content/[course_id]/__tests__/layout.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@ vi.mock('@iblai/iblai-js/data-layer', () => ({
162162
ExamInfo: {},
163163
}));
164164

165+
// Mock config — layout reads studioUrl for the Authoring tab
166+
vi.mock('@/lib/config', () => ({
167+
config: {
168+
urls: {
169+
studioUrl: vi.fn(() => 'https://studio.example.com'),
170+
},
171+
},
172+
}));
173+
165174
// Mock React.use
166175
vi.mock('react', async () => {
167176
const actual = await vi.importActual<typeof React>('react');
@@ -389,6 +398,87 @@ describe('CourseContentLayout', () => {
389398
expect(screen.getByText('Instructor')).toBeInTheDocument();
390399
});
391400

401+
describe('Authoring tab (platform admin only)', () => {
402+
it('renders Authoring tab for platform admin', () => {
403+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
404+
data: { is_platform_admin: true },
405+
} as any);
406+
407+
render(
408+
<CourseContentLayout params={defaultParams}>
409+
<div>children</div>
410+
</CourseContentLayout>,
411+
);
412+
expect(screen.getByText('Authoring')).toBeInTheDocument();
413+
});
414+
415+
it('hides Authoring tab for non-admin users', () => {
416+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
417+
data: { is_platform_admin: false },
418+
} as any);
419+
420+
render(
421+
<CourseContentLayout params={defaultParams}>
422+
<div>children</div>
423+
</CourseContentLayout>,
424+
);
425+
expect(screen.queryByText('Authoring')).not.toBeInTheDocument();
426+
});
427+
428+
it('Authoring tab points at studioUrl/course/<courseId> in a new tab', () => {
429+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
430+
data: { is_platform_admin: true },
431+
} as any);
432+
433+
const { container } = render(
434+
<CourseContentLayout params={defaultParams}>
435+
<div>children</div>
436+
</CourseContentLayout>,
437+
);
438+
439+
const authoringLink = Array.from(container.querySelectorAll('a')).find(
440+
(a) => a.textContent?.trim() === 'Authoring',
441+
);
442+
expect(authoringLink).toBeTruthy();
443+
// React.use mock decodes the param, so the courseId in the href has raw colons/plus.
444+
expect(authoringLink?.getAttribute('href')).toBe(
445+
'https://studio.example.com/course/course-v1:test+course+2024',
446+
);
447+
expect(authoringLink?.getAttribute('target')).toBe('_blank');
448+
expect(authoringLink?.getAttribute('rel')).toContain('noopener');
449+
});
450+
451+
it('Authoring tab is rendered immediately after Instructor tab', () => {
452+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
453+
data: { is_platform_admin: true },
454+
} as any);
455+
456+
const { container } = render(
457+
<CourseContentLayout params={defaultParams}>
458+
<div>children</div>
459+
</CourseContentLayout>,
460+
);
461+
462+
const tabLabels = Array.from(container.querySelectorAll('a'))
463+
.map((a) => a.textContent?.trim() ?? '')
464+
.filter((t) =>
465+
[
466+
'Agent',
467+
'Course',
468+
'Progress',
469+
'Dates',
470+
'Discussion',
471+
'Instructor',
472+
'Authoring',
473+
].includes(t),
474+
);
475+
const instructorIdx = tabLabels.indexOf('Instructor');
476+
const authoringIdx = tabLabels.indexOf('Authoring');
477+
expect(instructorIdx).toBeGreaterThanOrEqual(0);
478+
expect(authoringIdx).toBe(instructorIdx + 1);
479+
});
480+
});
481+
392482
it('renders children within layout', () => {
393483
render(
394484
<CourseContentLayout params={defaultParams}>

app/course-content/[course_id]/layout.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useGetDepartmentMemberCheckQuery } from '@/services/core';
2424
import { useGetCourseBlockDetailsQuery } from '@/services/course-metadata';
2525
import { Switch } from '@/components/ui/switch';
2626
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
27+
import { config } from '@/lib/config';
2728

2829
export default function CourseContentLayout({
2930
children,
@@ -294,6 +295,16 @@ export default function CourseContentLayout({
294295
Instructor
295296
</Link>
296297
)}
298+
{departmentMemberCheck?.is_platform_admin && (
299+
<a
300+
href={`${config.urls.studioUrl()}/course/${courseId}`}
301+
target="_blank"
302+
rel="noopener noreferrer"
303+
className="border-b-2 border-transparent px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700"
304+
>
305+
Authoring
306+
</a>
307+
)}
297308
</div>
298309
<div className="flex items-center gap-3 pr-4">
299310
{assessmentToggleVisible && (

app/courses/[course_id]/__tests__/page.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ vi.mock('@/lib/config', () => ({
2525
config: {
2626
urls: {
2727
lms: vi.fn(() => 'https://lms.example.com'),
28+
studioUrl: vi.fn(() => 'https://studio.example.com'),
2829
},
2930
},
3031
}));
@@ -383,6 +384,85 @@ describe('CourseDetailsPage', () => {
383384
expect(screen.queryByText('Configuration')).not.toBeInTheDocument();
384385
});
385386

387+
describe('Authoring tab (platform admin only)', () => {
388+
const mockCourse = {
389+
display_name: 'Test Course',
390+
course_image_asset_path: '/course-image.jpg',
391+
course_price: '$99',
392+
language: 'English',
393+
start_date: '2024-01-01',
394+
};
395+
396+
const mockCourseDetail = () =>
397+
vi.mocked(useCourseDetail).mockReturnValue({
398+
handleFetchCourseEligibilityInfo: mockHandleFetchCourseEligibilityInfo,
399+
handleFetchCourseInfo: mockHandleFetchCourseInfo,
400+
handleFetchCourseSyllabus: mockHandleFetchCourseSyllabus,
401+
handleOpenLesson: mockHandleOpenLesson,
402+
course: mockCourse,
403+
courseOutline: null,
404+
courseEligibility: { btn_label: 'Enroll', btn_action: vi.fn(), disabled: false },
405+
courseOutlineLoading: false,
406+
courseEligibilityLoading: false,
407+
courseButtonActionLoading: false,
408+
courseInfoLoadingState: 'successful',
409+
} as any);
410+
411+
it('renders Authoring tab for platform admin', () => {
412+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
413+
data: { is_platform_admin: true },
414+
} as any);
415+
mockCourseDetail();
416+
417+
render(<CourseDetailsPage />);
418+
419+
expect(screen.getByText('Authoring')).toBeInTheDocument();
420+
});
421+
422+
it('hides Authoring tab for non-admin users', () => {
423+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
424+
data: { is_platform_admin: false },
425+
} as any);
426+
mockCourseDetail();
427+
428+
render(<CourseDetailsPage />);
429+
430+
expect(screen.queryByText('Authoring')).not.toBeInTheDocument();
431+
});
432+
433+
it('Authoring tab points at studioUrl/course/<courseId> in a new tab', () => {
434+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
435+
data: { is_platform_admin: true },
436+
} as any);
437+
mockCourseDetail();
438+
439+
render(<CourseDetailsPage />);
440+
441+
const link = screen.getByText('Authoring').closest('a');
442+
// The mocked useParams returns the URL-encoded id; the page decodes it.
443+
expect(link?.getAttribute('href')).toBe(
444+
'https://studio.example.com/course/course-v1:test+course+2024',
445+
);
446+
expect(link?.getAttribute('target')).toBe('_blank');
447+
expect(link?.getAttribute('rel')).toContain('noopener');
448+
});
449+
450+
it('Authoring tab is rendered after Configuration tab', () => {
451+
vi.mocked(useGetDepartmentMemberCheckQuery).mockReturnValue({
452+
data: { is_platform_admin: true },
453+
} as any);
454+
mockCourseDetail();
455+
456+
const { container } = render(<CourseDetailsPage />);
457+
const tabRow = container.querySelector('div.flex.space-x-8');
458+
const labels = Array.from(tabRow?.children ?? []).map((el) => el.textContent?.trim());
459+
const configIdx = labels.indexOf('Configuration');
460+
const authoringIdx = labels.indexOf('Authoring');
461+
expect(configIdx).toBeGreaterThanOrEqual(0);
462+
expect(authoringIdx).toBe(configIdx + 1);
463+
});
464+
});
465+
386466
it('shows skeleton button when loading eligibility', () => {
387467
const mockCourse = {
388468
display_name: 'Test Course',

app/courses/[course_id]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ export default function CourseDetailsPage() {
178178
Configuration
179179
</button>
180180
)}
181+
{departmentMemberCheck?.is_platform_admin && (
182+
<a
183+
href={`${config.urls.studioUrl()}/course/${courseId}`}
184+
target="_blank"
185+
rel="noopener noreferrer"
186+
className="shrink-0 border-b-2 border-transparent px-1 py-3 text-sm font-medium whitespace-nowrap text-gray-500 hover:border-gray-300 hover:text-gray-700"
187+
>
188+
Authoring
189+
</a>
190+
)}
181191
</div>
182192
</div>
183193
</div>

app/profile/public/__tests__/page.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ vi.mock('lucide-react', () => ({
1515
// Mock helpers
1616
vi.mock('@/utils/helpers', () => ({
1717
getTenant: vi.fn(() => 'test-tenant'),
18+
getUserEmail: vi.fn(() => 'test@example.com'),
19+
onAccountDeleted: vi.fn(),
1820
}));
1921

2022
// Mock config
@@ -24,6 +26,7 @@ vi.mock('@/lib/config', () => ({
2426
enableGravatarOnProfilePic: vi.fn(() => 'true'),
2527
appName: vi.fn(() => 'skills'),
2628
platformBaseDomain: vi.fn(() => 'example.com'),
29+
mainPlatformKey: vi.fn(() => 'main'),
2730
},
2831
urls: {
2932
auth: vi.fn(() => 'https://auth.example.com'),

app/profile/public/page.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { MediaBox } from '@/components/profile/media-box';
1212
import { UserAvatar } from '@/components/header/profile/user-avatar';
1313
import { AppContext } from '@/components/client-layout';
1414
import { useTenantMetadata } from '@iblai/iblai-js/web-utils';
15-
import { getTenant, onAccountDeleted } from '@/utils/helpers';
15+
import { getTenant, getUserEmail, onAccountDeleted } from '@/utils/helpers';
1616
import { config } from '@/lib/config';
1717
import { Tenant } from '@iblai/iblai-js/web-utils';
1818
import { UserProfileModal } from '@iblai/iblai-js/web-containers/next';
@@ -32,6 +32,7 @@ export default function PublicProfilePage() {
3232

3333
const rbacPermissions = useAppSelector(selectRbacPermissions);
3434
const isAdmin = useIsAdmin();
35+
const email = getUserEmail();
3536

3637
const openUserProfile = useCallback(
3738
(targetTab: string) => {
@@ -183,18 +184,15 @@ export default function PublicProfilePage() {
183184
tenantKey: getTenant(),
184185
isAdmin,
185186
}}
187+
email={email}
188+
mainPlatformKey={config.settings.mainPlatformKey()}
186189
showMentorAIDisplayCheckbox={true}
187190
showLeaderboardDisplayCheckbox={true}
188191
showUsernameField={false}
189192
showPlatformName={false}
190193
useGravatarPicFallback={config.settings.enableGravatarOnProfilePic() !== 'false'}
191194
targetTab={userProfileTargetTab}
192195
onClose={handleCloseUserProfile}
193-
billingEnabled={false}
194-
billingURL={''}
195-
topUpEnabled={false}
196-
topUpURL={''}
197-
currentPlan={''}
198196
currentSPA={config.settings.appName() || 'skills'}
199197
authURL={config.urls.auth()}
200198
tenants={userTenants}

components/__tests__/user-profile-modal.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React from 'react';
55

66
vi.mock('@/utils/helpers', () => ({
77
getTenant: vi.fn(() => 'test-tenant'),
8+
getTenants: vi.fn(() => []),
89
getUserName: vi.fn(() => 'test-user'),
910
}));
1011

components/header/profile/__tests__/user-profile-button.test.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ let mockEnableGravatarOnProfilePic = 'true';
2727
vi.mock('@/utils/helpers', () => ({
2828
getTenant: () => 'test-tenant',
2929
getUserName: () => 'testuser',
30+
getUserEmail: () => 'test@example.com',
3031
handleLogout: mockHandleLogout,
3132
handleTenantSwitch: mockHandleTenantSwitch,
3233
onAccountDeleted: mockOnAccountDeleted,
@@ -57,6 +58,7 @@ vi.mock('@/lib/config', () => ({
5758
platformBaseDomain: () => 'example.com',
5859
enableRBAC: () => false,
5960
enableGravatarOnProfilePic: () => mockEnableGravatarOnProfilePic,
61+
mainPlatformKey: () => 'main',
6062
},
6163
urls: {
6264
auth: () => 'https://auth.example.com',
@@ -101,7 +103,6 @@ vi.mock('@iblai/iblai-js/web-containers/next', () => ({
101103
<span data-testid="show-account-tab">{String(props.showAccountTab)}</span>
102104
<span data-testid="show-help-link">{String(props.showHelpLink)}</span>
103105
<span data-testid="show-learner-mode-switch">{String(props.showLearnerModeSwitch)}</span>
104-
<span data-testid="billing-enabled">{String(props.billingEnabled)}</span>
105106
<span data-testid="current-spa">{props.currentSPA}</span>
106107
<span data-testid="enable-gravatar-on-profile-pic">
107108
{String(props.enableGravatarOnProfilePic)}
@@ -169,12 +170,6 @@ describe('UserProfileButton', () => {
169170
expect(screen.getByTestId('current-spa')).toHaveTextContent('skills');
170171
});
171172

172-
it('should disable billing', () => {
173-
render(<UserProfileButton />);
174-
175-
expect(screen.getByTestId('billing-enabled')).toHaveTextContent('false');
176-
});
177-
178173
it('should not show account tab', () => {
179174
render(<UserProfileButton />);
180175

components/header/profile/user-profile-button.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
handleLogout,
88
handleTenantSwitch,
99
onAccountDeleted,
10+
getUserEmail,
1011
} from '@/utils/helpers';
1112
import { Tenant } from '@iblai/iblai-js/web-utils';
1213
import { config } from '@/lib/config';
@@ -16,6 +17,7 @@ import { selectRbacPermissions, updateRbacPermissions } from '@/features/rbac';
1617

1718
export const UserProfileButton = () => {
1819
const username = getUserName();
20+
const email = getUserEmail();
1921
const tenantKey = getTenant();
2022
const { currentTenant, saveCurrentTenant } = useCurrentTenant();
2123
const { userTenants = [], saveUserTenants } = useUserTenants();
@@ -53,6 +55,8 @@ export const UserProfileButton = () => {
5355
username={username || undefined}
5456
userIsAdmin={isAdmin}
5557
userIsStudent={false}
58+
email={email}
59+
mainPlatformKey={config.settings.mainPlatformKey()}
5660
// Tenant data
5761
tenantKey={tenantKey}
5862
mentorId={undefined} // Skills app doesn't have mentor concept
@@ -73,12 +77,6 @@ export const UserProfileButton = () => {
7377
onLogout={handleLogout}
7478
onTenantChange={handleTenantChange}
7579
onHelpClick={handleHelpClick}
76-
// Modal props
77-
billingEnabled={false}
78-
billingURL=""
79-
topUpEnabled={false}
80-
topUpURL=""
81-
currentPlan=""
8280
// Custom components
8381
LearnerModeSwitchComponent={undefined}
8482
// Additional data

components/user-profile-modal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as React from 'react';
44

55
import { Profile, InviteUserDialog, InvitedUsersDialog } from '@iblai/iblai-js/web-containers';
66
import { Dialog, DialogTitle, DialogContent } from '@/components/ui/dialog';
7-
import { getTenant, getUserName } from '@/utils/helpers';
7+
import { getTenant, getTenants, getUserName } from '@/utils/helpers';
88
import { config } from '@/lib/config';
99

1010
interface UserProfileModalProps {
@@ -47,6 +47,7 @@ export function UserProfileModal({ isOpen, onClose }: UserProfileModalProps) {
4747
<Profile
4848
//onInviteClick={() => setIsInviteUserDialogOpen(true)}
4949
tenant={params.tenantKey}
50+
tenants={getTenants()}
5051
username={getUserName()}
5152
onClose={() => {}}
5253
customization={{

0 commit comments

Comments
 (0)