/** @jsxRuntime classic */ /** @jsx jsx */ import 'intersection-observer'; import { RefObject, useEffect, useMemo, useState, createContext, useContext, useRef } from 'react'; import { jsx } from '@keystone-ui/core'; import { Select, selectComponents, Radio } from '@keystone-ui/fields'; import { ListMeta } from '@keystone-6/core/types'; import { ApolloClient, gql, InMemoryCache, TypedDocumentNode, useApolloClient, useQuery, } from '@keystone-6/core/admin-ui/apollo'; const idField = '____id____'; const labelField = '____label____'; let nestedSetField = '____nestedSet____'; const LoadingIndicatorContext = createContext<{ count: number; ref: (element: HTMLElement | null) => void; }>({ count: 0, ref: () => {}, }); function useFilter(search: string, list: ListMeta) { return useMemo(() => { let conditions: Record[] = []; if (search.length) { const trimmedSearch = search.trim(); for (const field of Object.values(list.fields)) { if (field.search !== null) { conditions.push({ [field.path]: { contains: trimmedSearch, mode: field.search === 'insensitive' ? 'insensitive' : undefined, }, }); } } } return { OR: conditions }; }, [search, list]); } function useIntersectionObserver(cb: IntersectionObserverCallback, ref: RefObject) { useEffect(() => { let observer = new IntersectionObserver(cb, {}); let node = ref.current; if (node !== null) { observer.observe(node); return () => observer.unobserve(node); } }); } function useDebouncedValue(value: T, limitMs: number): T { const [debouncedValue, setDebouncedValue] = useState(() => value); useEffect(() => { let id = setTimeout(() => { setDebouncedValue(() => value); }, limitMs); return () => { clearTimeout(id); }; }, [value, limitMs]); return debouncedValue; } const initialItemsToLoad = 10; const subsequentItemsToLoad = 50; export const NestedSetInput = ({ autoFocus, isDisabled, isLoading, list, state, field, onChange, graphqlSelection, path, }: { autoFocus?: boolean; controlShouldRenderValue: boolean; isDisabled: boolean; isLoading?: boolean; list: ListMeta; onChange: void; state: { left: number; right: number; depth: number; parentId: string; }; field: string; graphqlSelection: string; path: string; }) => { const [search, setSearch] = useState(''); const [variant, setVariant] = useState('parentId'); const [loadingIndicatorElement, setLoadingIndicatorElement] = useState(null); const orderByField = { [path]: 'asc' }; const QUERY: TypedDocumentNode< { items: { [idField]: string; [labelField]: string | null }[]; count: number }, { where: Record; take: number; skip: number; orderBy: Record } > = gql` query NestedSetSelect($where: ${list.gqlNames.whereInputName}!, $take: Int!, $skip: Int!, $orderBy: [${list.gqlNames.listOrderName}!] ) { items: ${list.gqlNames.listQueryName}(where: $where, take: $take, skip: $skip, orderBy: $orderBy) { ${idField}: id ${labelField}: ${list.labelField} ${graphqlSelection} } count: ${list.gqlNames.listQueryCountName}(where: $where) } `; const debouncedSearch = useDebouncedValue(search, 200); const where = useFilter(debouncedSearch, list); const link = useApolloClient().link; const apolloClient = useMemo( () => new ApolloClient({ link, cache: new InMemoryCache({ typePolicies: { Query: { fields: { [list.gqlNames.listQueryName]: { keyArgs: ['where'], merge: (existing: readonly unknown[], incoming: readonly unknown[], { args }) => { const merged = existing ? existing.slice() : []; const { skip } = args!; for (let i = 0; i < incoming.length; ++i) { merged[skip + i] = incoming[i]; } return merged; }, }, }, }, }, }), }), [link, list.gqlNames.listQueryName] ); const generateIndent = (label: string, data: any) => { const depth = data && data[path] ? data[path].depth : 0; let text = ''; if (depth > 0) { for (let i = 0; i < depth; i++) { text += '- '; } } text += label; return text; }; const { data, error, loading, fetchMore } = useQuery(QUERY, { fetchPolicy: 'network-only', variables: { where, take: initialItemsToLoad, skip: 0, orderBy: orderByField }, client: apolloClient, }); const count = data?.count || 0; const options = data?.items?.map(({ [idField]: value, [labelField]: label, ...data }) => ({ value, label: generateIndent(label || value, data), [path]: data[path], isDisabled: !data[path] ? true : false, data, })) || []; let value: { [key: string]: any } = {}; if (state?.parentId) { value = options.find(option => option.value === state.parentId); } if (state?.prevSiblingOf) { value = options.find(option => option.value === state.prevSiblingOf); } if (state?.nextSiblingOf) { value = options.find(option => option.value === state.nextSiblingOf); } const loadingIndicatorContextVal = useMemo( () => ({ count, ref: setLoadingIndicatorElement, }), [count] ); const [lastFetchMore, setLastFetchMore] = useState<{ where: Record; list: ListMeta; skip: number; } | null>(null); useIntersectionObserver( ([{ isIntersecting }]) => { const skip = data?.items.length; if ( !loading && skip && isIntersecting && options.length < count && (lastFetchMore?.where !== where || lastFetchMore?.list !== list || lastFetchMore?.skip !== skip) ) { const QUERY: TypedDocumentNode< { items: { [idField]: string; [labelField]: string | null }[] }, { where: Record; take: number; skip: number; orderBy: Record } > = gql` query NestedSetSelectMore($where: ${list.gqlNames.whereInputName}!, $take: Int!, $skip: Int!, $orderBy: [${list.gqlNames.listOrderName}!]) { items: ${list.gqlNames.listQueryName}(where: $where, take: $take, skip: $skip, orderBy: $orderBy) { ${labelField}: ${list.labelField} ${idField}: id, ${graphqlSelection} } } `; setLastFetchMore({ list, skip, where }); fetchMore({ query: QUERY, variables: { where, take: subsequentItemsToLoad, skip, orderBy: orderByField, }, }) .then(() => { setLastFetchMore(null); }) .catch(() => { setLastFetchMore(null); }); } }, { current: loadingIndicatorElement } ); if (error) { return Error; } const radioVariants = [ { label: 'Parent', value: 'parentId', checked: true, disabled: false, }, { label: 'Before', value: 'prevSiblingOf', disabled: false, }, { label: 'After', value: 'nextSiblingOf', disabled: false, }, ]; const radioClass = { display: 'flex', marginTop: '1rem', flexDirection: 'column', }; const setPosition = e => { setVariant(e.target.value); }; const container = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'no-wrap', }; const selectWidth = { width: '80%', }; const radioButton = { marginBottom: '1rem', }; const prepareData = (value: { [key: string]: any }) => { if (value) { if (variant === '') { onChange({ parentId: value.value }); return; } switch (variant) { case 'parentId': onChange({ parentId: value.value }); return; case 'prevSiblingOf': onChange({ prevSiblingOf: value.value }); return; case 'nextSiblingOf': onChange({ nextSiblingOf: value.value }); return; } } return; }; return (