Files
frontend-app-authoring/src/library-authoring/component-info/ManageCollections.tsx
Jillian 811be226d1 feat: shareable URLs for library components and searches [FC-0076] (#1575)
Adds new routes and URL parameters to use when viewing and performing searches on library components. These changes allow these pages to be bookmarked or shared by copy/pasting the browser's current URL.

No changes were made to the UI.

Use cases covered:

* As an author working with content libraries, I want to easily share any component in a library with other people on my team, by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to easily share any search results with other people on my team, by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to bookmark a search in my browser and return to it at any time, with the same filters and keywords applied.
* As an author of a content library with public read access, I want to easily share any component in a library with any authors on the same Open edX instance, by copying the URL from my browser and sending it to them.
* As an author of a content library, I want to easily share a library's "Manage Team" page with other people on my team by copying the URL from my browser and sending it to them.
* As an author working with content libraries, I want to easily share any selected sidebar tab with other people on my team, by copying the URL from my browser and sending it to them.
2025-01-10 10:36:46 -05:00

215 lines
6.6 KiB
TypeScript

import { useContext, useState } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Icon, Scrollable, SelectableBox, Stack, StatefulButton, useCheckboxSetValues,
} from '@openedx/paragon';
import { Folder } from '@openedx/paragon/icons';
import {
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
useSearchContext,
} from '../../search-manager';
import messages from './messages';
import { useUpdateComponentCollections } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import { CollectionMetadata } from '../data/api';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
interface ManageCollectionsProps {
usageKey: string;
collections: CollectionMetadata[],
}
interface CollectionsDrawerProps extends ManageCollectionsProps {
onClose: () => void;
}
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
const type = 'checkbox';
const intl = useIntl();
const { hits } = useSearchContext();
const { showToast } = useContext(ToastContext);
const collectionKeys = collections.map((collection) => collection.key);
const [selectedCollections, {
add,
remove,
}] = useCheckboxSetValues(collectionKeys);
const [btnState, setBtnState] = useState('default');
const { libraryId } = useLibraryContext();
const updateCollectionsMutation = useUpdateComponentCollections(libraryId, usageKey);
const handleConfirmation = () => {
setBtnState('pending');
updateCollectionsMutation.mutateAsync(selectedCollections).then(() => {
showToast(intl.formatMessage(messages.manageCollectionsToComponentSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed));
}).finally(() => {
setBtnState('default');
onClose();
});
};
const handleChange = (e) => (e.target.checked ? add(e.target.value) : remove(e.target.value));
return (
<Stack gap={4}>
<Scrollable className="mt-3 p-1 border-bottom border-gray-100" style={{ height: '25vh' }}>
<SelectableBox.Set
value={selectedCollections}
type={type}
onChange={handleChange}
name="selectedCollections"
columns={1}
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
>
{hits.map((collectionHit) => (
<SelectableBox
className="d-inline-flex align-items-center shadow-none border border-gray-100"
value={collectionHit.blockId}
key={collectionHit.blockId}
inputHidden={false}
type={type}
aria-label={collectionHit.displayName}
>
<Stack className="ml-2" direction="horizontal" gap={2}>
<Icon src={Folder} />
<span>{collectionHit.displayName}</span>
</Stack>
</SelectableBox>
))}
</SelectableBox.Set>
</Scrollable>
<Stack direction="horizontal" gap={2}>
<Button
onClick={onClose}
className="font-weight-bold"
variant="tertiary"
>
{intl.formatMessage(messages.manageCollectionsToComponentCancelBtn)}
</Button>
<StatefulButton
onClick={handleConfirmation}
className="flex-grow-1 rounded-0"
variant="primary"
state={btnState}
labels={{
default: intl.formatMessage(messages.manageCollectionsToComponentConfirmBtn),
}}
/>
</Stack>
</Stack>
);
};
const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
const intl = useIntl();
const { libraryId } = useLibraryContext();
return (
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, 'type = "collection"']}
skipUrlUpdate
skipBlockTypeFetch
>
<Stack className="mt-2" gap={3}>
<FormattedMessage
{...messages.manageCollectionsText}
/>
<Stack gap={1} direction="horizontal">
<SearchKeywordsField
className="flex-grow-1"
placeholder={intl.formatMessage(messages.manageCollectionsSearchPlaceholder)}
/>
<SearchSortWidget iconOnly />
</Stack>
{/* Set key to update selection when component usageKey changes */}
<CollectionsSelectableBox
usageKey={usageKey}
collections={collections}
onClose={onClose}
key={usageKey}
/>
</Stack>
</SearchContextProvider>
);
};
const ComponentCollections = ({ collections, onManageClick }: {
collections?: string[];
onManageClick: () => void;
}) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
if (!collections?.length) {
return (
<Stack gap={3}>
<span className="border-bottom pb-3 border-gray-100">
<FormattedMessage {...messages.componentNotOrganizedIntoCollection} />
</span>
{!readOnly && (
<Button
onClick={onManageClick}
variant="primary"
>
{intl.formatMessage(messages.manageCollectionsAddBtnText)}
</Button>
)}
</Stack>
);
}
return (
<Stack gap={4} className="mt-2">
{collections.map((collection) => (
<Stack
className="border-bottom pb-4 border-gray-100"
gap={2}
direction="horizontal"
key={collection}
>
<Icon src={Folder} />
<span>{collection}</span>
</Stack>
))}
{!readOnly && (
<Button
onClick={onManageClick}
variant="outline-primary"
>
{intl.formatMessage(messages.manageCollectionsText)}
</Button>
)}
</Stack>
);
};
const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) => {
const { sidebarAction, resetSidebarAction, setSidebarAction } = useSidebarContext();
const collectionNames = collections.map((collection) => collection.title);
return (
sidebarAction === SidebarActions.JumpToAddCollections
? (
<AddToCollectionsDrawer
usageKey={usageKey}
collections={collections}
onClose={() => resetSidebarAction()}
/>
) : (
<ComponentCollections
collections={collectionNames}
onManageClick={() => setSidebarAction(SidebarActions.JumpToAddCollections)}
/>
)
);
};
export default ManageCollections;