Skip to content
Merged
1 change: 1 addition & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const App = () => {
<Route path="/library/create" element={<CreateLibrary />} />
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
<Route path="/component-picker" element={<ComponentPicker />} />
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
Expand Down
190 changes: 148 additions & 42 deletions src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,66 @@ import React, {
import type { ContentLibrary } from '../data/api';
import { useContentLibrary } from '../data/apiHooks';

interface SelectedComponent {
usageKey: string;
blockType: string;
}

export type ComponentSelectedEvent = (selectedComponent: SelectedComponent) => void;
export type ComponentSelectionChangedEvent = (selectedComponents: SelectedComponent[]) => void;

type NoComponentPickerType = {
componentPickerMode?: undefined;
onComponentSelected?: never;
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
};

type ComponentPickerSingleType = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
selectedComponents?: never;
addComponentToSelectedComponents?: never;
removeComponentFromSelectedComponents?: never;
};

type ComponentPickerMultipleType = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
selectedComponents: SelectedComponent[];
addComponentToSelectedComponents: ComponentSelectedEvent;
removeComponentFromSelectedComponents: ComponentSelectedEvent;
};

type ComponentPickerType = NoComponentPickerType | ComponentPickerSingleType | ComponentPickerMultipleType;

export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
ComponentInfo = 'component-info',
CollectionInfo = 'collection-info',
}

export enum SidebarAdditionalActions {
JumpToAddCollections = 'jump-to-add-collections',
}

export interface SidebarComponentInfo {
type: SidebarBodyComponentId;
id: string;
/** Additional action on Sidebar display */
additionalAction?: SidebarAdditionalActions;
}

export interface LibraryContextData {
export enum SidebarAdditionalActions {
JumpToAddCollections = 'jump-to-add-collections',
}

export type LibraryContextData = {
/** The ID of the current library */
libraryId: string;
libraryData?: ContentLibrary;
readOnly: boolean;
isLoadingLibraryData: boolean;
collectionId: string | undefined;
setCollectionId: (collectionId?: string) => void;
// Whether we're in "component picker" mode
componentPickerMode: boolean;
// Only show published components
showOnlyPublished: boolean;
// Sidebar stuff - only one sidebar is active at any given time:
Expand All @@ -61,7 +93,7 @@ export interface LibraryContextData {
openComponentEditor: (usageKey: string) => void;
closeComponentEditor: () => void;
resetSidebarAdditionalActions: () => void;
}
} & ComponentPickerType;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type change is not very pretty on this side, but I think it is beneficial, as we add many related states in the context. It makes things safer using the useLibraryContext hook on the components side.

i.e

  const {
    componentPickerMode,
    onComponentSelected,
    addComponentToSelectedComponents,
    removeComponentFromSelectedComponents,
  } = useLibraryContext();

  if (!componentPickerMode) {
    // ❌ This throws a type error
    addComponentToSelectedComponents({ usageKey: 'blockId', blockType: 'blockType' });
  };

  if (componentPickerMode === 'single') {
    // ❌ This throws a type error
    addComponentToSelectedComponents({ usageKey: 'blockId', blockType: 'blockType' });
  };

  if (componentPickerMode === 'multiple') {
    // ✅ This works
    addComponentToSelectedComponents({ usageKey: 'blockId', blockType: 'blockType' });
  };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's nice. But I think we will soon need to split up LibraryContext because it's getting too big and messy. We can instead have LibraryContext, SidebarContext, and ComponentPickerContext, and it will clean up our tests a lot too. The SidebarContext, and ComponentPickerContext can both have defaults so they don't always have to be present in order to read their values. (e.g. isSidebarOpen can just be false in the default context, even if there's no SidebarContextProvider available)


/**
* Library Context.
Expand All @@ -73,18 +105,35 @@ export interface LibraryContextData {
*/
const LibraryContext = React.createContext<LibraryContextData | undefined>(undefined);

interface LibraryProviderProps {
type NoComponentPickerProps = {
componentPickerMode?: undefined;
onComponentSelected?: never;
onChangeComponentSelection?: never;
};

export type ComponentPickerSingleProps = {
componentPickerMode: 'single';
onComponentSelected: ComponentSelectedEvent;
onChangeComponentSelection?: never;
};

export type ComponentPickerMultipleProps = {
componentPickerMode: 'multiple';
onComponentSelected?: never;
onChangeComponentSelection?: ComponentSelectionChangedEvent;
};

type ComponentPickerProps = NoComponentPickerProps | ComponentPickerSingleProps | ComponentPickerMultipleProps;

type LibraryProviderProps = {
children?: React.ReactNode;
libraryId: string;
/** The initial collection ID to show */
collectionId?: string;
/** The component picker mode is a special mode where the user is selecting a component to add to a Unit (or another
* XBlock) */
componentPickerMode?: boolean;
showOnlyPublished?: boolean;
/** Only used for testing */
initialSidebarComponentInfo?: SidebarComponentInfo;
}
} & ComponentPickerProps;

/**
* React component to provide `LibraryContext`
Expand All @@ -93,7 +142,9 @@ export const LibraryProvider = ({
children,
libraryId,
collectionId: collectionIdProp,
componentPickerMode = false,
componentPickerMode,
onComponentSelected,
onChangeComponentSelection,
showOnlyPublished = false,
initialSidebarComponentInfo,
}: LibraryProviderProps) => {
Expand All @@ -106,6 +157,8 @@ export const LibraryProvider = ({
const [componentBeingEdited, openComponentEditor] = useState<string | undefined>();
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);

const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);

/** Helper function to consume addtional action once performed.
Required to redo the action.
*/
Expand Down Expand Up @@ -140,44 +193,97 @@ export const LibraryProvider = ({
});
}, []);

const addComponentToSelectedComponents = useCallback<ComponentSelectedEvent>((
selectedComponent: SelectedComponent,
) => {
setSelectedComponents((prevSelectedComponents) => {
// istanbul ignore if: this should never happen
if (prevSelectedComponents.some((component) => component.usageKey === selectedComponent.usageKey)) {
return prevSelectedComponents;
}
const newSelectedComponents = [...prevSelectedComponents, selectedComponent];
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
}, []);

const removeComponentFromSelectedComponents = useCallback<ComponentSelectedEvent>((
selectedComponent: SelectedComponent,
) => {
setSelectedComponents((prevSelectedComponents) => {
// istanbul ignore if: this should never happen
if (!prevSelectedComponents.some((component) => component.usageKey === selectedComponent.usageKey)) {
return prevSelectedComponents;
}
const newSelectedComponents = prevSelectedComponents.filter(
(component) => component.usageKey !== selectedComponent.usageKey,
);
onChangeComponentSelection?.(newSelectedComponents);
return newSelectedComponents;
});
}, []);

const { data: libraryData, isLoading: isLoadingLibraryData } = useContentLibrary(libraryId);

const readOnly = componentPickerMode || !libraryData?.canEditLibrary;
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;

const context = useMemo<LibraryContextData>(() => ({
libraryId,
libraryData,
collectionId,
setCollectionId,
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
sidebarComponentInfo,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
resetSidebarAdditionalActions,
}), [
const context = useMemo<LibraryContextData>(() => {
const contextValue = {
libraryId,
libraryData,
collectionId,
setCollectionId,
readOnly,
isLoadingLibraryData,
showOnlyPublished,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
sidebarComponentInfo,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
resetSidebarAdditionalActions,
};
if (componentPickerMode === 'single') {
return {
...contextValue,
componentPickerMode,
onComponentSelected,
};
}
if (componentPickerMode === 'multiple') {
return {
...contextValue,
componentPickerMode,
selectedComponents,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
};
}
return contextValue;
}, [
libraryId,
collectionId,
setCollectionId,
libraryData,
readOnly,
isLoadingLibraryData,
componentPickerMode,
showOnlyPublished,
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
onChangeComponentSelection,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
Expand Down
84 changes: 71 additions & 13 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
Tabs,
Stack,
} from '@openedx/paragon';
import {
CheckBoxIcon,
CheckBoxOutlineBlank,
} from '@openedx/paragon/icons';

import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
import { ComponentMenu } from '../components';
Expand All @@ -18,14 +22,79 @@ import { getBlockType } from '../../generic/key-utils';
import { useLibraryBlockMetadata, usePublishComponent } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';

const AddComponentWidget = () => {
const intl = useIntl();

const {
sidebarComponentInfo,
componentPickerMode,
onComponentSelected,
addComponentToSelectedComponents,
removeComponentFromSelectedComponents,
selectedComponents,
} = useLibraryContext();

const usageKey = sidebarComponentInfo?.id;

// istanbul ignore if: this should never happen
if (!usageKey) {
throw new Error('usageKey is required');
}

if (!componentPickerMode) {
return null;
}

if (componentPickerMode === 'single') {
return (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
onClick={() => {
onComponentSelected({ usageKey, blockType: getBlockType(usageKey) });
}}
>
{intl.formatMessage(messages.componentPickerSingleSelect)}
</Button>
);
}

if (componentPickerMode === 'multiple') {
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
const handleChange = () => {
const selectedComponent = {
usageKey,
blockType: getBlockType(usageKey),
};
if (!isChecked) {
addComponentToSelectedComponents(selectedComponent);
} else {
removeComponentFromSelectedComponents(selectedComponent);
}
};

return (
<Button
variant="outline-primary"
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
onClick={handleChange}
>
{intl.formatMessage(messages.componentPickerMultipleSelect)}
</Button>
);
}

// istanbul ignore next: this should never happen
return null;
};

const ComponentInfo = () => {
const intl = useIntl();

const {
sidebarComponentInfo,
readOnly,
openComponentEditor,
componentPickerMode,
resetSidebarAdditionalActions,
} = useLibraryContext();

Expand Down Expand Up @@ -53,13 +122,6 @@ const ComponentInfo = () => {

const canEdit = canEditComponent(usageKey);

const handleAddComponentToCourse = () => {
window.parent.postMessage({
usageKey,
type: 'pickerComponentSelected',
category: getBlockType(usageKey),
}, '*');
};
const publishComponent = usePublishComponent(usageKey);
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
// Only can be published when the component has been modified after the last published date.
Expand Down Expand Up @@ -92,11 +154,7 @@ const ComponentInfo = () => {
<ComponentMenu usageKey={usageKey} />
</div>
)}
{componentPickerMode && (
<Button variant="outline-primary" className="m-1 text-nowrap flex-grow-1" onClick={handleAddComponentToCourse}>
{intl.formatMessage(messages.addComponentToCourse)}
</Button>
)}
<AddComponentWidget />
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
Expand Down
Loading