import { createContext, Dispatch, PropsWithChildren, SetStateAction, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, } from 'react'; import { useCombobox, UseComboboxStateChange } from 'downshift'; import { Box, Button, Table } from '../../next'; import { ConstrainedText, Icon, Loader, SearchInput, SecondaryText, Text, Tooltip, } from '../../index'; import styled from 'styled-components'; import { spacing, Stack, Wrap } from '../../spacing'; import { AttachableEntity, AttachmentOperation, AttachmentAction, } from './AttachmentTypes'; import { useQuery, UseQueryOptions } from 'react-query'; import { EmptyCell } from '../../components/tablev2/Tablev2.component'; import { tableRowHeight } from '../../components/tablev2/TableUtils'; type AttachableEntityWithPendingStatus = { isPending?: boolean; } & AttachableEntity; export type AttachmentTableProps< ENTITY_TYPE, ENTITY extends Record = Record, > = { initiallyAttachedEntities: AttachableEntity[]; initiallyAttachedEntitiesStatus: 'idle' | 'loading' | 'success' | 'error'; initialAttachmentOperations: AttachmentOperation[]; entityName: { plural: string; singular: string }; getNameQuery?: ( entity: AttachableEntity, ) => UseQueryOptions; searchEntityPlaceholder: string; onAttachmentsOperationsChanged: ( attachmentOperations: AttachmentOperation[], ) => void; filteredEntities: | { status: 'idle' } | { status: 'loading' | 'error'; data?: { number: number; entities: AttachableEntity[]; }; } | { status: 'success'; data: { number: number; entities: AttachableEntity[]; }; }; onEntitySearchChange: (search?: string) => void; }; const rowHeight = 'h48'; const MenuContainer = styled.ul<{ width: string; isOpen: boolean; searchInputIsFocused: boolean; }>` background-color: ${(props) => props.theme.backgroundLevel1}; background-clip: content-box; padding: 0; list-style: none; position: absolute; width: ${(props) => props.width}; z-index: 1; margin: 0; ${(props) => props.isOpen ? ` border-top-left-radius: 0; border-top-right-radius: 0; border-bottom-right-radius: 4px; border-bottom-left-radius: 4px; border: 1px solid ${props.theme.selectedActive}; ` : props.searchInputIsFocused ? `border-bottom: 1px solid ${props.theme.selectedActive};` : ''} border-top: 0; li { padding: ${spacing.r8}; cursor: pointer; border-top: 1px solid ${(props) => props.theme.backgroundLevel2}; &[aria-selected='true'] { background: ${(props) => props.theme.highlight}; } } `; const SearchBoxContainer = styled.div` position: relative; padding: ${spacing.r16}; `; const StyledSearchInput = styled(SearchInput)<{ searchInputIsFocused }>` flex-grow: 1; & > div:focus-within { border-color: ${(props) => props.theme.selectedActive}; border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 0; } `; const AttachmentTableContainer = styled.div` height: 100%; `; const CenterredSecondaryText = styled(SecondaryText)` display: block; text-align: center; line-height: ${tableRowHeight[rowHeight]}rem; `; const PrivateAttachmentContext = createContext<{ setResetAttachementTable: Dispatch< SetStateAction< ( initiallyAttachedEntities: AttachableEntity[], //Deliberately using any here because we can't use generics initialAttachmentOperations: AttachmentOperation[], ) => void > >; } | null>(null); const AttachmentContext = createContext<{ resetAttachmentTable: ( initiallyAttachedEntities: AttachableEntity[], //Deliberately using any here because we can't use generics initialAttachmentOperations: AttachmentOperation[], ) => void; } | null>(null); export const AttachmentProvider = < ENTITY_TYPE extends unknown, ENTITY extends Record = Record, >({ children, }: PropsWithChildren<{}>) => { const [resetAttachmentTable, setResetAttachementTable] = useState< ( initiallyAttachedEntities: AttachableEntity[], initialAttachmentOperations: AttachmentOperation[], ) => void >( ( _: AttachableEntity[], __: AttachmentOperation[], ) => {}, ); return ( {children} ); }; export const useAttachmentOperations = () => { const ctx = useContext(AttachmentContext); if (ctx === null) { throw new Error( "useAttachmentOperations can't be used outside AttachmentProvider", ); } return ctx; }; export const AttachmentTable = < ENTITY_TYPE, ENTITY extends Record = Record, >({ initiallyAttachedEntities, initiallyAttachedEntitiesStatus, initialAttachmentOperations, onAttachmentsOperationsChanged, entityName, searchEntityPlaceholder, getNameQuery, filteredEntities, onEntitySearchChange, }: AttachmentTableProps) => { const privateAttachmentContext = useContext(PrivateAttachmentContext); const exposedAttachmentContext = useContext(AttachmentContext); if (!privateAttachmentContext || !exposedAttachmentContext) { throw new Error('Cannot use AttachmentTable outside AttachmentProvider'); } //Desired attached entities and onAttachmentsOperationsChanged handling const convertInitiallyAttachedEntitiesToDesiredAttachedEntities = useCallback( ( initiallyAttachedEntities: AttachableEntity[], operations: AttachmentOperation< ENTITY_TYPE, ENTITY >[] = initialAttachmentOperations, ) => { return initiallyAttachedEntities .filter( (attachedEntities) => !operations.find((op) => op.entity.id === attachedEntities.id), ) .map((entity) => ({ ...entity, isPending: false, action: null, })); }, [initialAttachmentOperations], ); const convertInitiallyAttachementOperationsToDesiredAttachedEntities = useCallback( ( initialAttachmentOperations: AttachmentOperation[], ) => { return initialAttachmentOperations .filter((op) => op.action !== AttachmentAction.REMOVE) .map((op) => ({ ...op.entity, isPending: true, action: op.action, })); }, [], ); const [{ desiredAttachedEntities, attachmentsOperations }, dispatch] = useReducer( ( state: { desiredAttachedEntities: AttachableEntityWithPendingStatus[]; attachmentsOperations: AttachmentOperation[]; }, action: | { action: AttachmentAction.ADD; entity: AttachableEntity; } | { action: AttachmentAction.REMOVE; entity: AttachableEntity; } | { action: 'RESET_DESIRED_ATTACHED_ENTITIES'; entities: AttachableEntityWithPendingStatus[]; operations: AttachmentOperation[]; }, ) => { switch (action.action) { case 'RESET_DESIRED_ATTACHED_ENTITIES': return { desiredAttachedEntities: action.entities, attachmentsOperations: action.operations, }; case AttachmentAction.ADD: if ( !state.desiredAttachedEntities.find( (entity) => entity.id === action.entity.id, ) ) { const newAttachmentsOperations = [...state.attachmentsOperations]; const existingOperationIndexOnThisEntity = state.attachmentsOperations.findIndex( (operation) => operation.entity.id === action.entity.id, ); //When ADD, we check if it's already exist in operations. If so, we delete the previous operation and not proceed to the ADD. if ( existingOperationIndexOnThisEntity !== -1 && state.attachmentsOperations[existingOperationIndexOnThisEntity] .action === AttachmentAction.REMOVE ) { newAttachmentsOperations.splice( existingOperationIndexOnThisEntity, 1, ); const newState = { ...state, desiredAttachedEntities: [ { ...action.entity }, ...state.desiredAttachedEntities, ], attachmentsOperations: [...newAttachmentsOperations], }; return newState; } else { const newState = { ...state, desiredAttachedEntities: [ { ...action.entity, isPending: true }, ...state.desiredAttachedEntities, ], attachmentsOperations: [...newAttachmentsOperations, action], }; return newState; } } break; case AttachmentAction.REMOVE: if ( state.desiredAttachedEntities.find( (entity) => entity.id === action.entity.id, ) ) { const newDesiredAttachedEntities = [ ...state.desiredAttachedEntities, ]; newDesiredAttachedEntities.splice( state.desiredAttachedEntities.findIndex( (entity) => entity.id === action.entity.id, ), 1, ); const newAttachmentsOperations = [...state.attachmentsOperations]; const existingOperationIndexOnThisEntity = state.attachmentsOperations.findIndex( (operation) => operation.entity.id === action.entity.id, ); if ( existingOperationIndexOnThisEntity !== -1 && state.attachmentsOperations[existingOperationIndexOnThisEntity] .action === AttachmentAction.ADD ) { newAttachmentsOperations.splice( existingOperationIndexOnThisEntity, 1, ); } else if ( existingOperationIndexOnThisEntity !== -1 && state.attachmentsOperations[existingOperationIndexOnThisEntity] .action === AttachmentAction.REMOVE ) { return state; } else { newAttachmentsOperations.push(action); } const newState = { ...state, desiredAttachedEntities: newDesiredAttachedEntities, attachmentsOperations: newAttachmentsOperations, }; return newState; } break; } return state; }, { desiredAttachedEntities: [ ...convertInitiallyAttachedEntitiesToDesiredAttachedEntities( initiallyAttachedEntities, ), ...convertInitiallyAttachementOperationsToDesiredAttachedEntities( initialAttachmentOperations, ), ], attachmentsOperations: initialAttachmentOperations, }, ); useEffect(() => { onAttachmentsOperationsChanged(attachmentsOperations); }, [onAttachmentsOperationsChanged, attachmentsOperations]); const previousInitiallyAttachedEntitiesStatus = useRef( initiallyAttachedEntitiesStatus, ); useMemo(() => { if ( initiallyAttachedEntitiesStatus === 'success' && previousInitiallyAttachedEntitiesStatus.current !== initiallyAttachedEntitiesStatus ) { previousInitiallyAttachedEntitiesStatus.current = 'success'; dispatch({ action: 'RESET_DESIRED_ATTACHED_ENTITIES', entities: [ ...convertInitiallyAttachedEntitiesToDesiredAttachedEntities( initiallyAttachedEntities, ), ...convertInitiallyAttachementOperationsToDesiredAttachedEntities( initialAttachmentOperations, ), ], operations: initialAttachmentOperations, }); } else { previousInitiallyAttachedEntitiesStatus.current = initiallyAttachedEntitiesStatus; } }, [ initiallyAttachedEntitiesStatus, initiallyAttachedEntities, initialAttachmentOperations, convertInitiallyAttachedEntitiesToDesiredAttachedEntities, convertInitiallyAttachementOperationsToDesiredAttachedEntities, ]); useEffect(() => { privateAttachmentContext.setResetAttachementTable(() => { return ( newlyAttachedEntities: AttachableEntity[], newAttachmentOperations: AttachmentOperation[], ) => { dispatch({ action: 'RESET_DESIRED_ATTACHED_ENTITIES', entities: [ ...convertInitiallyAttachedEntitiesToDesiredAttachedEntities( newlyAttachedEntities, newAttachmentOperations, ), ...convertInitiallyAttachementOperationsToDesiredAttachedEntities( newAttachmentOperations, ), ], operations: newAttachmentOperations, }); }; }); }, [ convertInitiallyAttachedEntitiesToDesiredAttachedEntities, convertInitiallyAttachementOperationsToDesiredAttachedEntities, dispatch, ]); const resetRef = useRef<() => void | null>(null); const searchInputRef = useRef(null); const onSelectedItemChange = useCallback( ( onChangeParams: UseComboboxStateChange< AttachableEntity >, ) => { if (onChangeParams.selectedItem) { dispatch({ action: AttachmentAction.ADD, entity: onChangeParams.selectedItem, }); if (resetRef.current) resetRef.current(); if (searchInputRef.current) searchInputRef.current.blur(); } }, [resetRef], ); const { isOpen, getMenuProps, getInputProps, openMenu, getItemProps, reset } = useCombobox({ items: filteredEntities.status === 'success' ? filteredEntities.data.entities : [], onSelectedItemChange, onInputValueChange: ({ inputValue }) => { onEntitySearchChange(inputValue); }, }); useMemo(() => { //@ts-expect-error assigning to the ref is expected here resetRef.current = reset; }, [reset]); // UI styling states const [searchWidth, setSearchWidth] = useState('0px'); const [searchInputIsFocused, setSearchInputIsFocused] = useState(false); return ( }; }) => { const { data: asyncName, status } = useQuery({ ...(getNameQuery ? getNameQuery(entity) : { queryKey: ['fakeQuery'], queryFn: () => value }), enabled: !value, }); if (value) { return ; } if (status === 'error') { return ( <>An error occured while loading {entityName.singular} name ); } if (status === 'loading' || status === 'idle') { return <>Loading...; } if (status === 'success') { if (!asyncName) { return ; } return ; } return ; }, }, { Header: 'Attachment', accessor: 'isPending', cellStyle: { flex: 0.5, }, Cell: ({ value }: { value?: boolean }) => { return value ? <>Pending : <>Attached; }, }, { Header: , accessor: 'action', cellStyle: { textAlign: 'right', flex: 0.5, marginLeft: 'auto', marginRight: '0.5rem', }, Cell: ({ row: { original: entity }, }: { row: { original: AttachableEntity }; }) => (
); };