strapi-plugin-parent-child-relationships
Version:
Strapi plugin to create and manage parent-child relationships between content types using custom fields
520 lines (479 loc) • 14.7 kB
JSX
import React, { useRef, useState, useMemo, useEffect } from "react";
import {
Status,
Box,
Link,
Icon,
Flex,
TextButton,
Typography,
Tooltip,
VisuallyHidden,
Combobox,
} from "@strapi/design-system";
import { Cross, Refresh } from "@strapi/icons";
import PropTypes from "prop-types";
import { FixedSizeList as List } from "react-window";
import styled from "styled-components";
import { usePrev } from "../../hooks/usePrev";
import { Option } from "./components/Option";
import { RelationItem } from "./components/RelationItem";
import { RelationList } from "./components/RelationList";
import { RELATION_GUTTER, RELATION_ITEM_HEIGHT } from "./constants";
export const LinkEllipsis = styled(Link)`
display: block;
> span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
`;
export const DisconnectButton = styled.button`
svg path {
fill: ${({ theme, disabled }) =>
disabled ? theme.colors.neutral600 : theme.colors.neutral500};
}
&:hover svg path,
&:focus svg path {
fill: ${({ theme, disabled }) => !disabled && theme.colors.neutral600};
}
`;
const RelationInput = ({
canReorder,
description,
disabled,
error,
iconButtonAriaLabel,
id,
name,
numberOfRelationsToDisplay,
label,
labelAction,
labelLoadMore,
labelDisconnectRelation,
listAriaDescription,
liveText,
loadingMessage,
onCancel,
onDropItem,
onGrabItem,
noRelationsMessage,
onRelationConnect,
onRelationLoadMore,
onRelationDisconnect,
onRelationReorder,
onSearchNextPage,
onSearch,
placeholder,
publicationStateTranslations,
required,
relations: paginatedRelations,
searchResults,
size,
}) => {
const [textValue, setTextValue] = useState("");
const [overflow, setOverflow] = useState("");
const listRef = useRef();
const outerListRef = useRef();
const { data } = searchResults;
const relations = paginatedRelations.data;
const totalNumberOfRelations = relations.length ?? 0;
const dynamicListHeight = useMemo(
() =>
totalNumberOfRelations > numberOfRelationsToDisplay
? Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) *
(RELATION_ITEM_HEIGHT + RELATION_GUTTER) +
RELATION_ITEM_HEIGHT / 2
: Math.min(totalNumberOfRelations, numberOfRelationsToDisplay) *
(RELATION_ITEM_HEIGHT + RELATION_GUTTER),
[totalNumberOfRelations, numberOfRelationsToDisplay]
);
const shouldDisplayLoadMoreButton =
!!labelLoadMore && paginatedRelations.hasNextPage;
const options = useMemo(
() =>
data
.flat()
.filter(Boolean)
.map((result) => ({
...result,
value: result.id,
label: result.mainField,
})),
[data]
);
useEffect(() => {
if (totalNumberOfRelations <= numberOfRelationsToDisplay) {
return setOverflow("");
}
const handleNativeScroll = (e) => {
const parentScrollContainerHeight = e.target.parentNode.scrollHeight;
const maxScrollBottom = e.target.scrollHeight - e.target.scrollTop;
if (e.target.scrollTop === 0) {
return setOverflow("bottom");
}
if (maxScrollBottom === parentScrollContainerHeight) {
return setOverflow("top");
}
return setOverflow("top-bottom");
};
const outerListRefCurrent = outerListRef?.current;
if (
!paginatedRelations.isLoading &&
relations.length > 0 &&
outerListRefCurrent
) {
outerListRef.current.addEventListener("scroll", handleNativeScroll);
}
return () => {
if (outerListRefCurrent) {
outerListRefCurrent.removeEventListener("scroll", handleNativeScroll);
}
};
}, [
paginatedRelations,
relations,
numberOfRelationsToDisplay,
totalNumberOfRelations,
]);
const handleMenuOpen = (isOpen) => {
if (isOpen) {
onSearch();
}
};
/**
*
* @param {number} newIndex
* @param {number} currentIndex
*
* @returns {void}
*/
const handleUpdatePositionOfRelation = (newIndex, currentIndex) => {
if (onRelationReorder && newIndex >= 0 && newIndex < relations.length) {
onRelationReorder(currentIndex, newIndex);
}
};
const previewRelationsLength = usePrev(relations.length);
/**
* @type {React.MutableRefObject<'onChange' | 'loadMore'>}
*/
const updatedRelationsWith = useRef();
const handleLoadMore = () => {
updatedRelationsWith.current = "loadMore";
onRelationLoadMore();
};
useEffect(() => {
if (updatedRelationsWith.current === "onChange") {
setTextValue("");
}
if (
updatedRelationsWith.current === "onChange" &&
relations.length !== previewRelationsLength
) {
listRef.current?.scrollToItem(relations.length, "end");
updatedRelationsWith.current = undefined;
} else if (
updatedRelationsWith.current === "loadMore" &&
relations.length !== previewRelationsLength
) {
listRef.current?.scrollToItem(0, "start");
updatedRelationsWith.current = undefined;
}
}, [previewRelationsLength, relations]);
// console.log("RelationInput");
const ariaDescriptionId = `${name}-item-instructions`;
return (
<Flex gap={3} justifyContent="space-between" alignItems="end" wrap="wrap">
<Flex
direction="column"
alignItems="stretch"
basis={size <= 6 ? "100%" : "70%"}
gap={2}
>
<Combobox
autocomplete="list"
error={error}
name={name}
hint={description}
id={id}
required={required}
label={label}
labelAction={labelAction}
disabled={disabled}
placeholder={placeholder}
hasMoreItems={searchResults.hasNextPage}
loading={searchResults.isLoading}
onOpenChange={handleMenuOpen}
noOptionsMessage={() => noRelationsMessage}
loadingMessage={loadingMessage}
onLoadMore={() => {
onSearchNextPage();
}}
textValue={textValue}
onChange={(relationId) => {
if (!relationId) {
return;
}
onRelationConnect(options.find((opt) => opt.id === relationId));
updatedRelationsWith.current = "onChange";
}}
onTextValueChange={(text) => {
setTextValue(text);
}}
onInputChange={(event) => {
onSearch(event.currentTarget.value);
}}
>
{options.map((opt) => {
return <Option key={opt.id} {...opt} />;
})}
</Combobox>
{shouldDisplayLoadMoreButton && (
<TextButton
disabled={
paginatedRelations.isLoading ||
paginatedRelations.isFetchingNextPage
}
onClick={handleLoadMore}
loading={
paginatedRelations.isLoading ||
paginatedRelations.isFetchingNextPage
}
startIcon={<Refresh />}
>
{labelLoadMore}
</TextButton>
)}
</Flex>
{relations.length > 0 && (
<RelationList overflow={overflow}>
<VisuallyHidden id={ariaDescriptionId}>
{listAriaDescription}
</VisuallyHidden>
<VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
<List
height={dynamicListHeight}
ref={listRef}
outerRef={outerListRef}
itemCount={totalNumberOfRelations}
itemSize={RELATION_ITEM_HEIGHT + RELATION_GUTTER}
itemData={{
name,
ariaDescribedBy: ariaDescriptionId,
canDrag: canReorder,
disabled,
handleCancel: onCancel,
handleDropItem: onDropItem,
handleGrabItem: onGrabItem,
iconButtonAriaLabel,
labelDisconnectRelation,
onRelationDisconnect,
publicationStateTranslations,
relations,
updatePositionOfRelation: handleUpdatePositionOfRelation,
}}
itemKey={(index) =>
`${relations[index].mainField}_${relations[index].id}`
}
innerElementType="ol"
>
{ListItem}
</List>
</RelationList>
)}
</Flex>
);
};
const RelationsResult = PropTypes.shape({
data: PropTypes.arrayOf(
PropTypes.shape({
href: PropTypes.string,
id: PropTypes.number.isRequired,
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})
),
hasNextPage: PropTypes.bool,
isFetchingNextPage: PropTypes.bool.isRequired,
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
const SearchResults = PropTypes.shape({
data: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
href: PropTypes.string,
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
publicationState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
})
),
hasNextPage: PropTypes.bool,
isLoading: PropTypes.bool.isRequired,
isSuccess: PropTypes.bool.isRequired,
});
RelationInput.defaultProps = {
canReorder: false,
description: undefined,
disabled: false,
error: undefined,
labelAction: null,
labelLoadMore: null,
liveText: undefined,
onCancel: undefined,
onDropItem: undefined,
onGrabItem: undefined,
required: false,
relations: { data: [] },
searchResults: { data: [] },
};
RelationInput.propTypes = {
error: PropTypes.string,
canReorder: PropTypes.bool,
description: PropTypes.string,
disabled: PropTypes.bool,
iconButtonAriaLabel: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
labelAction: PropTypes.element,
labelLoadMore: PropTypes.string,
labelDisconnectRelation: PropTypes.string.isRequired,
listAriaDescription: PropTypes.string.isRequired,
liveText: PropTypes.string,
loadingMessage: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
noRelationsMessage: PropTypes.string.isRequired,
numberOfRelationsToDisplay: PropTypes.number.isRequired,
onCancel: PropTypes.func,
onDropItem: PropTypes.func,
onGrabItem: PropTypes.func,
onRelationConnect: PropTypes.func.isRequired,
onRelationDisconnect: PropTypes.func.isRequired,
onRelationLoadMore: PropTypes.func.isRequired,
onRelationReorder: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
onSearchNextPage: PropTypes.func.isRequired,
placeholder: PropTypes.string.isRequired,
publicationStateTranslations: PropTypes.shape({
draft: PropTypes.string.isRequired,
published: PropTypes.string.isRequired,
}).isRequired,
required: PropTypes.bool,
searchResults: SearchResults,
size: PropTypes.number.isRequired,
relations: RelationsResult,
};
/**
* This is in a separate component to enforce passing all the props the component requires to react-window
* to ensure drag & drop correctly works.
*/
const ListItem = ({ data, index, style }) => {
const {
ariaDescribedBy,
canDrag,
disabled,
handleCancel,
handleDropItem,
handleGrabItem,
iconButtonAriaLabel,
name,
labelDisconnectRelation,
onRelationDisconnect,
publicationStateTranslations,
relations,
updatePositionOfRelation,
} = data;
const { publicationState, href, mainField, id } = relations[index];
const statusColor = publicationState === "draft" ? "secondary" : "success";
return (
<RelationItem
ariaDescribedBy={ariaDescribedBy}
canDrag={canDrag}
disabled={disabled}
displayValue={String(mainField ?? id)}
iconButtonAriaLabel={iconButtonAriaLabel}
id={id}
index={index}
name={name}
endAction={
<DisconnectButton
data-testid={`remove-relation-${id}`}
disabled={disabled}
type="button"
onClick={() => onRelationDisconnect(relations[index])}
aria-label={labelDisconnectRelation}
>
<Icon width="12px" as={Cross} />
</DisconnectButton>
}
onCancel={handleCancel}
onDropItem={handleDropItem}
onGrabItem={handleGrabItem}
status={publicationState || undefined}
style={{
...style,
bottom: style.bottom ?? 0 + RELATION_GUTTER,
height: style.height ?? 0 - RELATION_GUTTER,
}}
updatePositionOfRelation={updatePositionOfRelation}
>
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
<Tooltip description={mainField ?? `${id}`}>
{href ? (
<LinkEllipsis to={href}>{mainField ?? id}</LinkEllipsis>
) : (
<Typography
textColor={disabled ? "neutral600" : "primary600"}
ellipsis
>
{mainField ?? id}
</Typography>
)}
</Tooltip>
</Box>
{publicationState && (
<Status variant={statusColor} showBullet={false} size="S">
<Typography fontWeight="bold" textColor={`${statusColor}700`}>
{publicationStateTranslations[publicationState]}
</Typography>
</Status>
)}
</RelationItem>
);
};
ListItem.defaultProps = {
data: {},
};
ListItem.propTypes = {
data: PropTypes.shape({
ariaDescribedBy: PropTypes.string.isRequired,
canDrag: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
handleCancel: PropTypes.func,
handleDropItem: PropTypes.func,
handleGrabItem: PropTypes.func,
iconButtonAriaLabel: PropTypes.string.isRequired,
labelDisconnectRelation: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onRelationDisconnect: PropTypes.func.isRequired,
publicationStateTranslations: PropTypes.shape({
draft: PropTypes.string.isRequired,
published: PropTypes.string.isRequired,
}).isRequired,
relations: PropTypes.arrayOf(
PropTypes.shape({
href: PropTypes.string,
id: PropTypes.number.isRequired,
publicationState: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
]),
mainField: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})
),
updatePositionOfRelation: PropTypes.func.isRequired,
}),
index: PropTypes.number.isRequired,
style: PropTypes.object.isRequired,
};
export default RelationInput;