Skip to content

Commit

Permalink
feat: add library component sidebar
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Aug 15, 2024
1 parent 95ac098 commit bdfb949
Show file tree
Hide file tree
Showing 11 changed files with 377 additions and 13 deletions.
31 changes: 28 additions & 3 deletions src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,67 @@ import React from 'react';
export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
ComponentInfo = 'component-info',
}

export interface LibraryContextData {
sidebarBodyComponent: SidebarBodyComponentId | null;
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
}

export const LibraryContext = React.createContext({
sidebarBodyComponent: null,
closeLibrarySidebar: () => {},
openAddContentSidebar: () => {},
openInfoSidebar: () => {},
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
} as LibraryContextData);

/**
* React component to provide `LibraryContext`
*/
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();

const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
const closeLibrarySidebar = React.useCallback(() => {
setSidebarBodyComponent(null);
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
(usageKey: string) => {
setCurrentComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);

Check warning on line 49 in src/library-authoring/common/context.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/common/context.tsx#L47-L49

Added lines #L47 - L49 were not covered by tests
},
[],
);

const context = React.useMemo(() => ({
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
]);

return (
Expand Down
51 changes: 51 additions & 0 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* eslint-disable react/require-default-props */
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Tab,
Tabs,
Stack,
} from '@openedx/paragon';

import { ComponentMenu } from '../components';
import messages from './messages';

interface ComponentInfoProps {
usageKey: string;
}

const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
const intl = useIntl();

Check warning on line 19 in src/library-authoring/component-info/ComponentInfo.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfo.tsx#L19

Added line #L19 was not covered by tests

return (

Check warning on line 21 in src/library-authoring/component-info/ComponentInfo.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfo.tsx#L21

Added line #L21 was not covered by tests
<Stack>
<Stack direction="horizontal" className="d-flex justify-content-around">
<Button disabled variant="outline-primary rounded-0">
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled variant="outline-primary rounded-0">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</Stack>
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="preview"
>
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
Preview tab placeholder
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
</Tab>
</Tabs>
</Stack>
);
};

export default ComponentInfo;
109 changes: 109 additions & 0 deletions src/library-authoring/component-info/ComponentInfoHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/* eslint-disable react/require-default-props */
import React, { useState, useContext, useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Icon,
IconButton,
Stack,
Form,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';

import { LoadingSpinner } from '../../generic/Loading';
import AlertError from '../../generic/alert-error';
import { ToastContext } from '../../generic/toast-context';
import { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks';
import messages from './messages';

interface ComponentInfoHeaderProps {
usageKey: string;
}

const ComponentInfoHeader = ({ usageKey }: ComponentInfoHeaderProps) => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);

Check warning on line 24 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L23-L24

Added lines #L23 - L24 were not covered by tests

const {
data: xblockFields,
isError,
error,
isLoading,
} = useXBlockFields(usageKey);

Check warning on line 31 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L31

Added line #L31 was not covered by tests

const updateMutation = useUpdateXBlockFields(usageKey);
const { showToast } = useContext(ToastContext);

Check warning on line 34 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L33-L34

Added lines #L33 - L34 were not covered by tests

const handleSaveDisplayName = useCallback(
(event) => {
const newDisplayName = event.target.value;

Check warning on line 38 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L36-L38

Added lines #L36 - L38 were not covered by tests
if (newDisplayName && newDisplayName !== xblockFields?.displayName) {
updateMutation.mutateAsync({

Check warning on line 40 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L40

Added line #L40 was not covered by tests
metadata: {
display_name: newDisplayName,
},
}).then(() => {
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));

Check warning on line 47 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L44-L47

Added lines #L44 - L47 were not covered by tests
});
}
setIsActive(false);

Check warning on line 50 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L50

Added line #L50 was not covered by tests
},
[xblockFields, showToast, intl],
);

const handleClick = () => {
setIsActive(true);

Check warning on line 56 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L55-L56

Added lines #L55 - L56 were not covered by tests
};

const hanldeOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {

Check warning on line 59 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L59

Added line #L59 was not covered by tests
if (event.key === 'Enter') {
handleSaveDisplayName(event);

Check warning on line 61 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L61

Added line #L61 was not covered by tests
} else if (event.key === 'Escape') {
setIsActive(false);

Check warning on line 63 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L63

Added line #L63 was not covered by tests
}
};

if (isError) {
return <AlertError error={error} />;

Check warning on line 68 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L68

Added line #L68 was not covered by tests
}

if (isLoading) {
return <LoadingSpinner />;

Check warning on line 72 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L72

Added line #L72 was not covered by tests
}

return (

Check warning on line 75 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L75

Added line #L75 was not covered by tests
<Stack direction="horizontal">
{ inputIsActive
? (
<Form.Control

Check warning on line 79 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L79

Added line #L79 was not covered by tests
autoFocus
name="displayName"
id="displayName"
type="text"
aria-label="Display name input"
defaultValue={xblockFields.displayName}
onBlur={handleSaveDisplayName}
onKeyDown={hanldeOnKeyDown}
/>
)
: (
<>

Check warning on line 91 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L91

Added line #L91 was not covered by tests
<span className="font-weight-bold m-1.5">
{xblockFields.displayName}
</span>
{true && ( // Add condition to check if user has permission to edit
<IconButton

Check warning on line 96 in src/library-authoring/component-info/ComponentInfoHeader.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/component-info/ComponentInfoHeader.tsx#L96

Added line #L96 was not covered by tests
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editNameButtonAlt)}
onClick={handleClick}
/>
)}
</>
)}
</Stack>
);
};

export default ComponentInfoHeader;
2 changes: 2 additions & 0 deletions src/library-authoring/component-info/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as ComponentInfo } from './ComponentInfo';
export { default as ComponentInfoHeader } from './ComponentInfoHeader';
50 changes: 50 additions & 0 deletions src/library-authoring/component-info/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';

// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = _defineMessages as typeof defineMessagesType;

const messages = defineMessages({
editNameButtonAlt: {
id: 'course-authoring.library-authoring.component.edit-name.alt',
defaultMessage: 'Edit componet name',
description: 'Alt text for edit component name icon button',
},
updateComponentSuccessMsg: {
id: 'course-authoring.library-authoring.component.update.success',
defaultMessage: 'Component updated successfully.',
description: 'Message when the component is updated successfully',
},
updateComponentErrorMsg: {
id: 'course-authoring.library-authoring.component.update.error',
defaultMessage: 'There was an error updating the component.',
description: 'Message when there is an error when updating the component',
},
editComponentButtonTitle: {
id: 'course-authoring.library-authoring.component.edit.title',
defaultMessage: 'Edit component',
description: 'Title for edit component button',
},
publishComponentButtonTitle: {
id: 'course-authoring.library-authoring.component.publish.title',
defaultMessage: 'Publish component',
description: 'Title for publish component button',
},
previewTabTitle: {
id: 'course-authoring.library-authoring.component.preview-tab.title',
defaultMessage: 'Preview',
description: 'Title for preview tab',
},
manageTabTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.title',
defaultMessage: 'Manage',
description: 'Title for manage tab',
},
detailsTabTitle: {
id: 'course-authoring.library-authoring.component.details-tab.title',
defaultMessage: 'Details',
description: 'Title for details tab',
},
});

export default messages;
14 changes: 11 additions & 3 deletions src/library-authoring/components/ComponentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { updateClipboard } from '../../generic/data/api';
import TagCount from '../../generic/tag-count';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit, Highlight } from '../../search-manager';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';

Expand All @@ -24,7 +25,7 @@ type ComponentCardProps = {
blockTypeDisplayName: string,
};

const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
Expand Down Expand Up @@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
};

const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
const {
openComponentInfoSidebar,
} = useContext(LibraryContext);

const {
blockType,
formatted,
Expand All @@ -84,15 +89,18 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps

return (
<Container className="library-component-card">
<Card>
<Card
isClickable
onClick={() => openComponentInfoSidebar(usageKey)}
>
<Card.Header
className={`library-component-header ${getComponentStyleColor(blockType)}`}
title={
<Icon src={componentIcon} className="library-component-header-icon" />
}
actions={(
<ActionRow>
<ComponentCardMenu usageKey={usageKey} />
<ComponentMenu usageKey={usageKey} />
</ActionRow>
)}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/library-authoring/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as LibraryComponents } from './LibraryComponents';
export { ComponentMenu } from './ComponentCard';
35 changes: 35 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr
* Get the URL for paste clipboard content into library.
*/
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
/**
* Get the URL for the xblock metadata API.
*/
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;

export interface ContentLibrary {
id: string;
Expand Down Expand Up @@ -64,6 +68,12 @@ export interface LibrariesV2Response {
results: ContentLibrary[],
}

export interface XBlockFields {
displayName: string;
metadata: Record<string, unknown>;
data: string;
}

/* Additional custom parameters for the API request. */
export interface GetLibrariesV2CustomParams {
/* (optional) Library type, default `complex` */
Expand Down Expand Up @@ -110,6 +120,13 @@ export interface LibraryPasteClipboardRequest {
blockId: string;
}

export interface UpdateXBlockFieldsRequest {
data?: unknown;
metadata?: {
display_name?: string;
};
}

/**
* Fetch block types of a library
*/
Expand Down Expand Up @@ -211,3 +228,21 @@ export async function libraryPasteClipboard({
);
return data;
}

/**
* Fetch xblock fields.
*/
export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsApiUrl(usageKey));
return camelCaseObject(data);

Check warning on line 237 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L236-L237

Added lines #L236 - L237 were not covered by tests
}

/**
* Update xblock fields.
*/
export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest): Promise<undefined> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);

Check warning on line 245 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L244-L245

Added lines #L244 - L245 were not covered by tests

return camelCaseObject(data);

Check warning on line 247 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L247

Added line #L247 was not covered by tests
}
Loading

0 comments on commit bdfb949

Please sign in to comment.