@gravityforms/components
Version:
UI components for use in Gravity Forms development. Both React and vanilla js flavors.
770 lines (717 loc) • 25.2 kB
JavaScript
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;