Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions src/generic/key-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export function getBlockType(usageKey: string): string {
throw new Error(`Invalid usageKey: ${usageKey}`);
}

/**
* Parses a library key and returns the organization and library name as an object.
*/
export function parseLibraryKey(libraryKey: string): { org: string, lib: string } {
const [, org, lib] = libraryKey?.split(':') || [];
if (org && lib) {
return { org, lib };
}
throw new Error(`Invalid libraryKey: ${libraryKey}`);
}

/**
* Given a usage key like `lb:org:lib:html:id`, get the library key
* @param usageKey e.g. `lb:org:lib:html:id`
Expand Down
128 changes: 111 additions & 17 deletions src/studio-home/card-item/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import {
Card,
Dropdown,
Icon,
IconButton,
Stack,
} from '@openedx/paragon';
import { MoreHoriz } from '@openedx/paragon/icons';
import { AccessTime, ArrowForward, MoreHoriz } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { Link } from 'react-router-dom';

import { useWaffleFlags } from '../../data/apiHooks';
import { COURSE_CREATOR_STATES } from '../../constants';
import { useWaffleFlags } from '@src/data/apiHooks';
import { COURSE_CREATOR_STATES } from '@src/constants';
import { parseLibraryKey } from '@src/generic/key-utils';
import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';

Expand All @@ -24,7 +27,12 @@ interface BaseProps {
rerunLink?: string | null;
courseKey?: string;
isLibraries?: boolean;
isMigrated?: boolean;
migratedToKey?: string;
migratedToTitle?: string;
migratedToCollectionKey?: string;
}

type Props = BaseProps & (
/** If we should open this course/library in this MFE, this is the path to the edit page, e.g. '/course/foo' */
{ path: string, url?: never } |
Expand All @@ -35,6 +43,33 @@ type Props = BaseProps & (
{ url: string, path?: never }
);

const PrevToNextName = ({ from, to }: { from: React.ReactNode, to?: React.ReactNode }) => (
<Stack direction="horizontal" gap={2}>
<span>{from}</span>
{to
&& (
<>
<Icon src={ArrowForward} size="xs" className="mb-1" />
<span>{to}</span>
</>
)}
</Stack>
);

const MakeLinkOrSpan = ({
when, to, children, className,
}: {
when: boolean,
to: string,
children: React.ReactNode;
className?: string,
}) => {
if (when) {
return <Link className={className} to={to}>{children}</Link>;
}
return <span className={className}>{children}</span>;
};

/**
* A card on the Studio home page that represents a Course or a Library
*/
Expand All @@ -49,6 +84,10 @@ const CardItem: React.FC<Props> = ({
courseKey = '',
path,
url,
isMigrated = false,
migratedToKey,
migratedToTitle,
migratedToCollectionKey,
}) => {
const intl = useIntl();
const {
Expand All @@ -63,29 +102,66 @@ const CardItem: React.FC<Props> = ({
? url
: new URL(url, getConfig().STUDIO_BASE_URL).toString()
);
const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`;
const readOnlyItem = !(lmsLink || rerunLink || url || path);
const showActions = !(readOnlyItem || isLibraries);
const isShowRerunLink = allowCourseReruns
&& rerunCreatorStatus
&& courseCreatorStatus === COURSE_CREATOR_STATES.granted;
const hasDisplayName = (displayName ?? '').trim().length ? displayName : courseKey;
const title = (displayName ?? '').trim().length ? displayName : courseKey;

const getSubtitle = useCallback(() => {
let subtitle = isLibraries ? <>{org} / {number}</> : <>{org} / {number} / {run}</>;
if (isMigrated && migratedToKey) {
const migratedToKeyObj = parseLibraryKey(migratedToKey);
subtitle = (
<PrevToNextName
from={subtitle}
to={<>{migratedToKeyObj.org} / {migratedToKeyObj.lib}</>}
/>
);
}
return subtitle;
}, [isLibraries, org, number, run, migratedToKey, isMigrated]);

const collectionLink = () => {
let libUrl = `/library/${migratedToKey}`;
if (migratedToCollectionKey) {
libUrl += `/collection/${migratedToCollectionKey}`;
}
return libUrl;
};

const getTitle = useCallback(() => (
<PrevToNextName
from={(
<MakeLinkOrSpan
when={!readOnlyItem}
to={destinationUrl}
className="card-item-title"
>
{title}
</MakeLinkOrSpan>
)}
to={
isMigrated && migratedToTitle && (
<MakeLinkOrSpan
when={!readOnlyItem}
to={`/library/${migratedToKey}`}
className="card-item-title"
>
{migratedToTitle}
</MakeLinkOrSpan>
)
}
/>
), [readOnlyItem, isMigrated, destinationUrl, migratedToTitle, title]);

return (
<Card className="card-item">
<Card.Header
size="sm"
title={!readOnlyItem ? (
<Link
className="card-item-title"
to={destinationUrl}
>
{hasDisplayName}
</Link>
) : (
<span className="card-item-title">{displayName}</span>
)}
subtitle={subtitle}
title={getTitle()}
subtitle={getSubtitle()}
actions={showActions && (
<Dropdown>
<Dropdown.Toggle
Expand All @@ -110,6 +186,24 @@ const CardItem: React.FC<Props> = ({
</Dropdown>
)}
/>
{isMigrated && migratedToKey
&& (
<Card.Status className="bg-white pt-0 text-gray-500">
<Stack direction="horizontal" gap={2}>
<Icon src={AccessTime} size="sm" className="mb-1" />
{intl.formatMessage(messages.libraryMigrationStatusText)}
<b>
<MakeLinkOrSpan
when={!readOnlyItem}
to={collectionLink()}
className="text-info-500"
>
{migratedToTitle}
</MakeLinkOrSpan>
</b>
</Stack>
</Card.Status>
)}
</Card>
);
};
Expand Down
40 changes: 25 additions & 15 deletions src/studio-home/data/api.js → src/studio-home/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,64 @@ export const getCourseNotificationUrl = (url) => new URL(url, getApiBaseUrl()).h

/**
* Get's studio home data.
* @returns {Promise<Object>}
*/
export async function getStudioHomeData() {
export async function getStudioHomeData(): Promise<object> {
const { data } = await getAuthenticatedHttpClient().get(getStudioHomeApiUrl());
return camelCaseObject(data);
}

/** Get list of courses from the deprecated non-paginated API */
export async function getStudioHomeCourses(search) {
export async function getStudioHomeCourses(search: string) {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/courses${search}`);
return camelCaseObject(data);
}
/**
* Get's studio home courses.
* @param {string} search - Query string parameters for filtering the courses.
* TODO: this should be an object with a list of allowed keys and values; not a string.
* @param {object} customParams - Additional custom parameters for the API request.
* @returns {Promise<Object>} - A Promise that resolves to the response data containing the studio home courses.
* Note: We are changing /api/contentstore/v1 to /api/contentstore/v2 due to upcoming breaking changes.
* Features such as pagination, filtering, and ordering are better handled in the new version.
* Please refer to this PR for further details: https://github.com/openedx/edx-platform/pull/34173
*/
export async function getStudioHomeCoursesV2(search, customParams) {
export async function getStudioHomeCoursesV2(search: string, customParams: object): Promise<object> {
const customParamsFormat = snakeCaseObject(customParams);
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v2/home/courses${search}`, { params: customParamsFormat });
return camelCaseObject(data);
}

export async function getStudioHomeLibraries() {
const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/contentstore/v1/home/libraries`);
export interface LibraryV1Data {
displayName: string;
libraryKey: string;
url: string;
org: string;
number: string;
canEdit: boolean;
isMigrated: boolean;
migratedToTitle?: string;
migratedToKey?: string;
migratedToCollectionKey?: string | null;
migratedToCollectionTitle?: string | null;
}

export interface LibrariesV1ListData {
libraries: LibraryV1Data[];
}

export async function getStudioHomeLibraries(): Promise<LibrariesV1ListData> {
const { data } = await getAuthenticatedHttpClient().get(`${getStudioHomeApiUrl()}/libraries`);
return camelCaseObject(data);
}

/**
* Handle course notification requests.
* @param {string} url
* @returns {Promise<Object>}
*/
export async function handleCourseNotification(url) {
export async function handleCourseNotification(url: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient().delete(getCourseNotificationUrl(url));
return camelCaseObject(data);
}

/**
* Send user request to course creation access for studio home data.
* @returns {Promise<Object>}
*/
export async function sendRequestForCourseCreator() {
export async function sendRequestForCourseCreator(): Promise<object> {
const { data } = await getAuthenticatedHttpClient().post(getRequestCourseCreatorUrl());
return camelCaseObject(data);
}
18 changes: 18 additions & 0 deletions src/studio-home/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { getStudioHomeLibraries } from './api';

export const studioHomeQueryKeys = {
all: ['studioHome'],
/**
* Base key for list of v1/legacy libraries
*/
librariesV1: () => [...studioHomeQueryKeys.all, 'librariesV1'],
};

export const useLibrariesV1Data = (enabled: boolean = true) => (
useQuery({
queryKey: studioHomeQueryKeys.librariesV1(),
queryFn: () => getStudioHomeLibraries(),
enabled,
})
);
17 changes: 0 additions & 17 deletions src/studio-home/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import {
getStudioHomeData,
sendRequestForCourseCreator,
handleCourseNotification,
getStudioHomeLibraries,
getStudioHomeCoursesV2,
} from './api';
import {
fetchStudioHomeDataSuccess,
updateLoadingStatuses,
updateSavingStatuses,
fetchLibraryDataSuccess,
fetchCourseDataSuccessV2,
} from './slice';

Expand Down Expand Up @@ -58,20 +56,6 @@ function fetchOnlyStudioHomeData() {
return fetchStudioHomeData('', false, {}, false, false);
}

function fetchLibraryData() {
return async (dispatch) => {
dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.IN_PROGRESS }));

try {
const libraryData = await getStudioHomeLibraries();
dispatch(fetchLibraryDataSuccess(libraryData));
dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLoadingStatuses({ libraryLoadingStatus: RequestStatus.FAILED }));
}
};
}

function handleDeleteNotificationQuery(url) {
return async (dispatch) => {
dispatch(updateSavingStatuses({ deleteNotificationSavingStatus: RequestStatus.PENDING }));
Expand Down Expand Up @@ -103,7 +87,6 @@ function requestCourseCreatorQuery() {
export {
fetchStudioHomeData,
fetchOnlyStudioHomeData,
fetchLibraryData,
requestCourseCreatorQuery,
handleDeleteNotificationQuery,
};
14 changes: 14 additions & 0 deletions src/studio-home/factories/mockApiResponses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export const generateGetStudioHomeLibrariesApiResponse = () => ({
org: 'Cambridge',
number: '123',
canEdit: true,
isMigrated: false,
},
{
displayName: 'Legacy library 1',
libraryKey: 'library-v1:UNIX+LG1',
url: '/library/library-v1:UNIX+LG1',
org: 'unix',
number: 'LG1',
canEdit: true,
isMigrated: true,
migratedToKey: 'lib:UNIX:CS1',
migratedToTitle: 'Imported library',
migratedToCollectionKey: 'imported-content',
migratedToCollectionTitle: 'Imported content',
},
],
});
Expand Down
5 changes: 5 additions & 0 deletions src/studio-home/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ const messages = defineMessages({
id: 'course-authoring.studio-home.organization.input.no-options',
defaultMessage: 'No options',
},
libraryMigrationStatusText: {
id: 'course-authoring.studio-home.library-v1.card.status',
description: 'Status text in v1 library card in studio informing user of its migration status',
defaultMessage: 'Previously migrated library. Any problem bank links were already moved to',
},
});

export default messages;
2 changes: 1 addition & 1 deletion src/studio-home/scss/StudioHome.scss
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
}

.card-item-title {
font: normal var(--pgn-typography-font-weight-normal) 1.125rem/1.75rem var(--pgn-typography-font-family-base);
font: normal var(--pgn-typography-font-weight-semi-bold) 1.125rem/1.75rem var(--pgn-typography-font-family-base);
color: var(--pgn-color-black);
margin-bottom: .1875rem;
}
Expand Down
Loading