UNPKG

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
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;