UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

770 lines (717 loc) 25.2 kB
import { React, ReactDND, PropTypes, classnames } from '@gravityforms/libraries'; import { IdProvider, useIdContext } from '@gravityforms/react-utils'; import { spacerClasses, sprintf } from '@gravityforms/utils'; import KanbanColumn from './KanbanColumn'; import KanbanCard from './KanbanCard'; import KanbanControls from './KanbanControls'; import KanbanDragLayer from './KanbanDragLayer'; import KanbanEmpty from './KanbanEmpty'; import itemTypes from './item-types'; import { getModules } from './utils'; import Button from '../../elements/Button'; import Grid from '../../elements/Grid'; import Heading from '../../elements/Heading'; const { forwardRef, useCallback, useRef, useState } = React; const { useDrop } = ReactDND; const LEFT = 'left'; const RIGHT = 'right'; const ABOVE = 'above'; const BELOW = 'below'; const GRID_COLUMN = 'gridColumn'; const GRID_LEFT = 'gridLeft'; const GRID_RIGHT = 'gridRight'; const GRID_CARD = 'gridCard'; const COLUMN_TOP = 'columnTop'; const COLUMN_BOTTOM = 'columnBottom'; const NEEDS_I18N_LABEL = 'Needs i18n'; const KanbanComponent = forwardRef( ( props, ref ) => { const id = useIdContext(); const [ screenReaderText, setScreenReaderText ] = useState( '' ); const gridRef = useRef( null ); const columnToIndexRef = useRef( null ); const [ columnHoveredIndexState, setColumnHoveredIndexState ] = useState( null ); const [ columnHoveredPositionState, setColumnHoveredPositionState ] = useState( null ); const [ columnHoveredTargetState, setColumnHoveredTargetState ] = useState( null ); const cardToIndexRef = useRef( null ); const [ cardToColumnState, setCardToColumnState ] = useState( null ); const [ cardHoveredIndexState, setCardHoveredIndexState ] = useState( null ); const [ cardHoveredPositionState, setCardHoveredPositionState ] = useState( null ); const [ cardHoveredTargetState, setCardHoveredTargetState ] = useState( null ); const { addColumnButtonAttributes = {}, addColumnButtonClasses = [], afterHeading = null, customAttributes = {}, customClasses = [], data = [], height = 0, i18n = {}, isLoading = false, modules = [], onCardMove = () => {}, onChange = () => {}, onColumnEdit = () => {}, onColumnMove = () => {}, screenReaderAttributes = {}, screenReaderClasses = [], titleAttributes = {}, titleClasses = [], spacing = '', } = props; const columnItemType = `${ itemTypes.KANBAN_COLUMN }_${ id }`; const { ActiveFilters } = getModules( modules, [ 'ActiveFilters' ] ); /** * @function moveColumn * @description Moves a column from one index to another. * * @since 5.8.4 * * @param {number} fromIndex The index of the column being moved. * @param {number} toIndex The index to move the column to. * @param {string} itemId The ID of the column being moved. */ const moveColumn = ( fromIndex, toIndex, itemId ) => { const updatedData = Array.from( data ); const movedColumn = updatedData[ fromIndex ]; let oldNextColumn = null; let newNextColumn = null; // placeholder for moving card logic. const notFiltered = true; // @todo: get from filter module state. if ( notFiltered ) { // Fill the hole left by the moved column. if ( fromIndex === updatedData.length - 1 ) { // Is last column in kanban, nothing to update. } else { // has next column, update next column prevId to moved column prevId. oldNextColumn = updatedData[ fromIndex + 1 ]; if ( oldNextColumn ) { oldNextColumn.prevId = movedColumn.prevId; } } } // Move column. updatedData.splice( fromIndex, 1 ); updatedData.splice( toIndex, 0, movedColumn ); if ( notFiltered ) { // Update moved column's previous ID and the new next column's previous ID. if ( toIndex !== 0 ) { // Moved column is not the first card, updated moved column prevId. const newPrevColumn = updatedData[ toIndex - 1 ]; movedColumn.prevId = newPrevColumn.id; } else { movedColumn.prevId = null; } if ( toIndex !== updatedData.length - 1 ) { // Moved column is not last column, update new next column prevId. newNextColumn = updatedData[ toIndex + 1 ]; newNextColumn.prevId = movedColumn.id; } } setScreenReaderText( i18n?.moveColumn ? sprintf( i18n.moveColumn, itemId, toIndex ) : NEEDS_I18N_LABEL ); onColumnMove( updatedData, { movedColumn, oldNextColumn, newNextColumn, }, ); onChange( updatedData ); }; /** * @function moveCard * @description Moves a card from one column to another. * * @since 5.8.4 * * @param {string} fromId The id of the column the card is being moved from. * @param {string} toId The id of the column the card is being moved to. * @param {number} fromIndex The index of the card being moved. * @param {number} toIndex The index to move the card to. * @param {string} itemId The ID of the card being moved. */ const moveCard = ( fromId, toId, fromIndex, toIndex, itemId ) => { const copyData = Array.from( data ); let fromColumn = copyData.find( ( column ) => `${ id }-${ column.id }` === fromId ); let toColumn = copyData.find( ( column ) => `${ id }-${ column.id }` === toId ); if ( ! fromColumn || ! toColumn ) { return; } fromColumn = { ...fromColumn, cards: Array.from( fromColumn.cards ), }; toColumn = { ...toColumn, cards: Array.from( toColumn.cards ), }; if ( fromColumn.id === toColumn.id ) { fromColumn = toColumn; } const movedCard = fromColumn.cards[ fromIndex ]; let oldNextCard = null; let newNextCard = null; // placeholder for moving card logic. const notFiltered = true; // @todo: get from filter module state. if ( notFiltered ) { // Fill the hole left by the moved card. if ( fromColumn.cards.length === 1 || fromIndex === fromColumn.cards.length - 1 ) { // is only card or last card in column, nothing to update. } else { // has next card, update next card prevId to moved card prevId. oldNextCard = fromColumn.cards[ fromIndex + 1 ]; if ( oldNextCard ) { oldNextCard.prevId = movedCard.prevId; } } } // Move card. fromColumn.cards.splice( fromIndex, 1 ); toColumn.cards.splice( toIndex, 0, movedCard ); if ( notFiltered ) { // Update moved card's previous ID and the new next card's previous ID. if ( toIndex !== 0 ) { // Moved card is not first card, update moved card prevId. const newPrevCard = toColumn.cards[ toIndex - 1 ]; movedCard.prevId = newPrevCard.id; } else { movedCard.prevId = null; } if ( toIndex !== toColumn.cards.length - 1 ) { // Moved card is not last card, update new next card prevId. newNextCard = toColumn.cards[ toIndex + 1 ]; newNextCard.prevId = movedCard.id; } } const updatedData = copyData.map( ( column ) => { if ( fromColumn.id === toColumn.id && column.id === toColumn.id ) { return toColumn; } if ( column.id === fromColumn.id ) { return fromColumn; } if ( column.id === toColumn.id ) { return toColumn; } return column; } ); setScreenReaderText( i18n?.moveCard ? sprintf( i18n.moveCard, itemId, toId, toIndex ) : NEEDS_I18N_LABEL ); onCardMove( updatedData, { movedCard, oldNextCard, newNextCard, fromColumn, toColumn, }, ); onChange( updatedData ); }; /** * @function endColumnDrag * @description Resets the column drag state. * * @since 5.8.4 * */ const endColumnDrag = () => { columnToIndexRef.current = null; setColumnHoveredIndexState( null ); setColumnHoveredPositionState( null ); setColumnHoveredTargetState( null ); }; /** * @function onColumnHover * @description Handles the hover state for a column. * * @since 5.8.4 * * @param {object} item The item being dragged. * @param {object} monitor The drag monitor. * @param {string} columnId The ID of the column being hovered over. */ const onColumnHover = ( item, monitor, columnId ) => { const clientOffset = monitor.getClientOffset(); if ( ! clientOffset ) { return; } // Get all card elements. const column = gridRef.current?.querySelector( `#${ columnId }` ); const cardElements = column?.querySelectorAll( '.gform-kanban__card' ) || []; let hoveredId = null; let hoveredIndex = null; let hoveredPosition = null; let hoveredTarget = null; // Find which card element the mouse is over. for ( let i = 0; i < cardElements.length; i++ ) { const element = cardElements[ i ]; const rect = element.getBoundingClientRect(); if ( i === 0 && clientOffset.y < rect.top ) { hoveredTarget = COLUMN_TOP; break; } if ( clientOffset.y >= rect.top && clientOffset.y <= rect.bottom ) { hoveredId = element.id; hoveredIndex = i; hoveredTarget = GRID_CARD; const hoveredMiddleY = ( rect.bottom - rect.top ) / 2; const hoverClientY = clientOffset.y - rect.top; // Determine which side of the hovered card we are over hoveredPosition = hoverClientY < hoveredMiddleY ? ABOVE : BELOW; break; } if ( i === cardElements.length - 1 && clientOffset.y > rect.bottom ) { hoveredTarget = COLUMN_BOTTOM; break; } } if ( cardElements.length === 0 ) { hoveredTarget = COLUMN_TOP; } // Get the to index based on the hovered position. let toIndex = hoveredIndex; if ( hoveredId !== null ) { // If the card is in a different column and the hovered position is below, add 1 to the index. // If the hovered position is above, keep the index as is. if ( item.columnId !== columnId && hoveredPosition === BELOW ) { toIndex = hoveredIndex + 1; } else if ( item.columnId === columnId && item.index !== hoveredIndex ) { // Compute the final insertion index relative to the original drag index if ( hoveredPosition === ABOVE ) { toIndex = item.index > hoveredIndex ? hoveredIndex : hoveredIndex - 1; } else { // below toIndex = item.index >= hoveredIndex ? hoveredIndex + 1 : hoveredIndex; } } } else if ( hoveredTarget !== GRID_CARD ) { // Not over card. if ( hoveredTarget === COLUMN_TOP ) { // If hovering over the top of the column, set to index to 0. toIndex = 0; } else if ( columnId !== item.columnId ) { toIndex = cardElements.length; } else { toIndex = cardElements.length - 1; } } cardToIndexRef.current = toIndex; setCardToColumnState( columnId ); setCardHoveredIndexState( hoveredIndex ); setCardHoveredPositionState( hoveredPosition ); setCardHoveredTargetState( hoveredTarget ); }; /** * @function onColumnDrop * @description Handles the drop event for a column. * * @since 5.8.4 * * @param {object} item The item being dropped. */ const onColumnDrop = ( item ) => { const fromIndex = item.index; const fromId = item.columnId; const toIndex = cardToIndexRef.current; const toId = cardToColumnState; if ( ( typeof fromIndex === 'number' && typeof toIndex === 'number' ) && ( ( fromId === toId && fromIndex !== toIndex ) || fromId !== toId ) ) { moveCard( fromId, toId, fromIndex, toIndex, item.id ); } }; /** * @function endCardDrag * @description Resets the card drag state. * * @since 5.8.4 * */ const endCardDrag = () => { cardToIndexRef.current = null; setCardToColumnState( null ); setCardHoveredIndexState( null ); setCardHoveredPositionState( null ); setCardHoveredTargetState( null ); }; /** * @function updateColumnLabel * @description Updates the label properties of a column. * * @since 5.8.4 * * @param {string} columnId The ID of the column to update. * @param {object} newLabelProps The new label properties to set. */ const updateColumnLabel = ( columnId, newLabelProps ) => { let column; const updatedData = data.map( ( col ) => { if ( `${ id }-${ col.id }` === columnId ) { column = { ...col, props: { ...col.props, columnLabelAttributes: { ...col.props.columnLabelAttributes, ...newLabelProps, }, }, }; return column; } return col; } ); onColumnEdit( updatedData, column, newLabelProps ); onChange( updatedData ); }; const [ , drop ] = useDrop( { accept: columnItemType, hover: ( item, monitor ) => { const clientOffset = monitor.getClientOffset(); if ( ! clientOffset ) { return; } // Get all column elements. const columnElements = gridRef.current?.querySelectorAll( '.gform-kanban__grid-item' ); let hoveredId = null; let hoveredIndex = null; let hoveredPosition = null; let hoveredTarget = null; // Find which column element the mouse is over. for ( let i = 0; i < columnElements.length; i++ ) { const element = columnElements[ i ]; const rect = element.getBoundingClientRect(); if ( i === 0 && clientOffset.x < rect.left ) { hoveredTarget = GRID_LEFT; break; } if ( clientOffset.x >= rect.left && clientOffset.x <= rect.right ) { hoveredId = data[ i ]?.id; hoveredIndex = i; hoveredTarget = GRID_COLUMN; const hoveredMiddleX = ( rect.right - rect.left ) / 2; const hoverClientX = clientOffset.x - rect.left; // Determine which side of the hovered column we are over hoveredPosition = hoverClientX < hoveredMiddleX ? LEFT : RIGHT; break; } if ( i === columnElements.length - 1 && clientOffset.x > rect.right ) { hoveredTarget = GRID_RIGHT; break; } } // Get the to index based on the hovered position. let toIndex = hoveredIndex; if ( hoveredId !== null && item.index !== hoveredIndex ) { // Compute the final insertion index relative to the original drag index if ( hoveredPosition === LEFT ) { toIndex = item.index > hoveredIndex ? hoveredIndex : hoveredIndex - 1; } else { // right toIndex = item.index >= hoveredIndex ? hoveredIndex + 1 : hoveredIndex; } } else if ( hoveredTarget !== GRID_COLUMN ) { // Not over column. toIndex = hoveredTarget === GRID_RIGHT ? columnElements.length - 1 : 0; } columnToIndexRef.current = toIndex; setColumnHoveredIndexState( hoveredIndex ); setColumnHoveredPositionState( hoveredPosition ); setColumnHoveredTargetState( hoveredTarget ); }, drop: ( item ) => { const fromIndex = item.index; const toIndex = columnToIndexRef.current; if ( typeof fromIndex === 'number' && typeof toIndex === 'number' && fromIndex !== toIndex ) { moveColumn( fromIndex, toIndex, item.id ); } }, } ); const componentProps = { ...customAttributes, className: classnames( { 'gform-kanban': true, ...spacerClasses( spacing ), }, customClasses ), style: { ...( customAttributes.style || {} ), height: height > 0 ? `${ height }px` : 'auto', }, }; const titleProps = { size: 'text-lg', tagName: 'h3', weight: 'medium', ...titleAttributes, customClasses: classnames( [ 'gform-kanban__header-title' ], titleClasses ), }; const screenReaderProps = { id: `${ id }-kanban-screen-reader-text`, className: classnames( [ 'gform-kanban__screen-reader-text', 'gform-visually-hidden', ], screenReaderClasses ), 'aria-live': 'polite', ...screenReaderAttributes, }; const gridProps = { alignItems: 'stretch', columnSpacing: 3, container: true, customClasses: [ 'gform-kanban__grid' ], elementType: 'div', justifyContent: 'flex-start', }; const gridItemProps = { customClasses: [ 'gform-kanban__grid-item' ], elementType: 'div', item: true, }; const addColumnButtonProps = { label: NEEDS_I18N_LABEL, icon: 'plus-regular', iconPosition: 'leading', size: 'size-height-s', type: 'white', ...addColumnButtonAttributes, customClasses: classnames( [ 'gform-kanban__add-column-button' ], addColumnButtonClasses ), }; const dragLayerProps = { ...props, kanbanId: id, }; const setRefs = useCallback( ( node ) => { if ( ref ) { ref.current = node; } drop( node ); }, [ ref, drop ] ); return ( <div { ...componentProps } ref={ setRefs }> { ( i18n?.heading || afterHeading ) && ( <header className="gform-kanban__header"> <Heading { ...titleProps }>{ i18n?.heading || NEEDS_I18N_LABEL }</Heading> { afterHeading } </header> ) } <KanbanControls { ...props } /> { ActiveFilters && <ActiveFilters.ActiveFilters { ...props } /> } <div { ...screenReaderProps }> { screenReaderText } </div> <div className="gform-kanban__grid-container"> <div className="gform-kanban__grid-inner"> <Grid { ...gridProps } ref={ gridRef }> { data.map( ( column, index ) => { const columnId = `${ id }-${ column.id }`; const cardHoveredWhenEmpty = column.cards.length === 0 && cardToColumnState === columnId && cardHoveredTargetState === COLUMN_TOP; const hoveredLeft = ( index === columnHoveredIndexState && columnHoveredPositionState === LEFT ) || ( columnHoveredTargetState === GRID_LEFT && index === 0 ); const hoveredRight = ( index === columnHoveredIndexState && columnHoveredPositionState === RIGHT ) || ( columnHoveredTargetState === GRID_RIGHT && index === data.length - 1 ); const columnProps = { ...column.props, cardHoveredWhenEmpty, columnData: column, hoveredLeft, hoveredRight, i18n, id: columnId, index, isFirstColumn: index === 0, isLastColumn: index === data.length - 1, isLoading, kanbanId: id, moveColumn, onDragEnd: endColumnDrag, onDrop: onColumnDrop, onHover: onColumnHover, updateColumnLabel, }; return ( <Grid key={ columnId } { ...gridItemProps }> <KanbanColumn { ...columnProps }> { column.cards.map( ( card, cardIndex ) => { if ( isLoading ) { return <KanbanCard key={ card?.id || cardIndex } isLoading={ true } />; } const CardComponent = card.component || KanbanCard; const hoveredAbove = cardToColumnState === columnId && ( ( cardIndex === cardHoveredIndexState && cardHoveredPositionState === ABOVE ) || ( cardHoveredTargetState === COLUMN_TOP && cardIndex === 0 ) ); const hoveredBelow = cardToColumnState === columnId && ( ( cardIndex === cardHoveredIndexState && cardHoveredPositionState === BELOW ) || ( cardHoveredTargetState === COLUMN_BOTTOM && cardIndex === column.cards.length - 1 ) ); const cardProps = { ...card.props, columns: data.map( ( col ) => ( { id: `${ id }-${ col.id }`, label: col?.props?.columnLabelAttributes?.label || '', count: col.cards.length, } ) ), columnId, hoveredAbove, hoveredBelow, i18n, index: cardIndex, isFirstCard: cardIndex === 0, isLastCard: cardIndex === column.cards.length - 1, kanbanId: id, moveCard, onDragEnd: endCardDrag, }; return <CardComponent { ...cardProps } key={ card.props.id } />; } ) } </KanbanColumn> </Grid> ); } ) } <Grid { ...gridItemProps }> <Button { ...addColumnButtonProps } /> </Grid> </Grid> <KanbanDragLayer { ...dragLayerProps } /> <KanbanEmpty { ...props } /> </div> </div> </div> ); } ); /** * @module Kanban * @description A Kanban board component that allows for drag-and-drop reordering of columns and cards. * * @since 5.8.4 * * @param {object} props The properties for the Kanban component. * @param {object} props.addColumnButtonAttributes Attributes for the Add Column button. * @param {string|Array|object} props.addColumnButtonClasses Classes for the Add Column button. * @param {JSX.Element|null} props.afterHeading Content to display after the heading. * @param {JSX.Element|null} props.controlsLeft Content to display on the left side of the controls. * @param {object} props.customAttributes Custom attributes for the Kanban component. * @param {string|Array|object} props.customClasses Custom classes for the Kanban component. * @param {Array} props.data The data for the Kanban columns and cards. * @param {JSX.Element|null} props.EmptyImage The image to display when there are no cards. * @param {number} props.height The height of the Kanban component. * @param {string} props.id The ID of the Kanban component. * @param {object} props.i18n I18n strings for the Kanban component. * @param {boolean} props.isLoading Whether the Kanban is in a loading state. * @param {Function} props.onCardMove Callback function to handle card movement. * @param {Function} props.onChange Callback function to handle changes to the Kanban data. * @param {Function} props.onColumnEdit Callback function to handle column edits. * @param {Function} props.onColumnMove Callback function to handle column movement. * @param {Array} props.modules The modules to include in the Kanban component. * @param {object} props.moduleAttributes Attributes for the modules. * @param {object} props.moduleState The state of the modules. * @param {object} props.screenReaderAttributes Attributes for the screen reader text. * @param {string|Array|object} props.screenReaderClasses Classes for the screen reader text. * @param {Function} props.setIsLoading Function to set the loading state of the Kanban. * @param {string|number|Array|object} props.spacing Spacing for the Kanban component. * @param {object} props.titleAttributes Attributes for the title. * @param {string|Array|object} props.titleClasses Classes for the title. * @param {Function} props.updateModuleState Function to update the module state. * @param {boolean} props.useAjax Whether to use AJAX for data fetching. * @param {object} ref The ref to the Kanban component. * * @return {JSX.Element} The Kanban component. */ const Kanban = forwardRef( ( props, ref ) => { const defaultProps = { addColumnButtonAttributes: {}, addColumnButtonClasses: [], afterHeading: null, controlsLeft: null, customAttributes: {}, customClasses: [], data: [], EmptyImage: null, height: 0, id: '', i18n: {}, isLoading: false, modules: [], moduleAttributes: {}, moduleState: {}, onCardMove: () => {}, onChange: () => {}, onColumnEdit: () => {}, onColumnMove: () => {}, screenReaderAttributes: {}, screenReaderClasses: [], setIsLoading: () => {}, spacing: '', titleAttributes: {}, titleClasses: [], updateModuleState: () => {}, useAjax: false, }; const combinedProps = { ...defaultProps, ...props }; const { id: idProp } = combinedProps; const idProviderProps = { id: idProp }; return ( <IdProvider { ...idProviderProps }> <KanbanComponent { ...combinedProps } ref={ ref } /> </IdProvider> ); } ); Kanban.propTypes = { addColumnButtonAttributes: PropTypes.object, addColumnButtonClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), afterHeading: PropTypes.element, customAttributes: PropTypes.object, customClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), data: PropTypes.arrayOf( PropTypes.object ), EmptyImage: PropTypes.oneOfType( [ PropTypes.element, PropTypes.func, PropTypes.object, ] ), height: PropTypes.number, id: PropTypes.string, i18n: PropTypes.object, isLoading: PropTypes.bool, modules: PropTypes.array, moduleAttributes: PropTypes.object, moduleState: PropTypes.object, onCardMove: PropTypes.func, onChange: PropTypes.func, onColumnEdit: PropTypes.func, onColumnMove: PropTypes.func, screenReaderAttributes: PropTypes.object, screenReaderClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), setIsLoading: PropTypes.func, spacing: PropTypes.oneOfType( [ PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object, ] ), titleAttributes: PropTypes.object, titleClasses: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array, PropTypes.object, ] ), updateModuleState: PropTypes.func, useAjax: PropTypes.bool, }; Kanban.displayName = 'Kanban'; export default Kanban;