Skip to content

Commit

Permalink
Relationship select fixes (#6839)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown authored Oct 28, 2021
1 parent 36174f8 commit d1141ea
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-insects-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': patch
---

Fixed overfetching, not filtering by the search and erroring when an id is searched for in the relationship select UI.
35 changes: 19 additions & 16 deletions packages/keystone/src/admin-ui/system/createAdminMeta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { GraphQLString } from 'graphql';
import type { KeystoneConfig, AdminMetaRootVal } from '../../../types';
import { GraphQLString, isInputObjectType } from 'graphql';
import { KeystoneConfig, AdminMetaRootVal, QueryMode } from '../../types';
import { humanize } from '../../lib/utils';
import { InitialisedList } from '../../lib/core/types-for-lists';

Expand Down Expand Up @@ -97,12 +97,21 @@ export function createAdminMeta(
);
}
const whereInputFields = list.types.where.graphQLType.getFields();
const possibleSearchFields = new Map<string, 'default' | 'insensitive' | null>();

for (const fieldKey of Object.keys(list.fields)) {
const filterType = whereInputFields[fieldKey]?.type;
const fieldFilterFields = isInputObjectType(filterType) ? filterType.getFields() : undefined;
if (fieldFilterFields?.contains?.type === GraphQLString) {
possibleSearchFields.set(
fieldKey,
fieldFilterFields?.mode?.type === QueryMode.graphQLType ? 'insensitive' : 'default'
);
}
}
if (config.lists[key].ui?.searchFields === undefined) {
const labelField = adminMetaRoot.listsByKey[key].labelField;
const potentialFilterField =
whereInputFields[`${labelField}_contains_i`] || whereInputFields[`${labelField}_contains`];
if (potentialFilterField?.type === GraphQLString) {
if (possibleSearchFields.has(labelField)) {
searchFields.add(labelField);
}
}
Expand All @@ -113,17 +122,11 @@ export function createAdminMeta(
// FIXME: Disabling this entirely for now until the Admin UI can properly
// handle `omit: ['read']` correctly.
if (field.graphql.isEnabled.read === false) continue;
let search: 'default' | 'insensitive' | null = null;
if (searchFields.has(fieldKey)) {
if (whereInputFields[`${fieldKey}_contains_i`]?.type === GraphQLString) {
search = 'insensitive';
} else if (whereInputFields[`${fieldKey}_contains`]?.type === GraphQLString) {
search = 'default';
} else {
throw new Error(
`The ui.searchFields option on the ${key} list includes '${fieldKey}' but that field doesn't have a contains filter that accepts a GraphQL String`
);
}
let search = searchFields.has(fieldKey) ? possibleSearchFields.get(fieldKey) ?? null : null;
if (searchFields.has(fieldKey) && search === null) {
throw new Error(
`The ui.searchFields option on the ${key} list includes '${fieldKey}' but that field doesn't have a contains filter that accepts a GraphQL String`
);
}
adminMetaRoot.listsByKey[key].fields.push({
label: field.label ?? humanize(fieldKey),
Expand Down
2 changes: 1 addition & 1 deletion packages/keystone/src/admin-ui/templates/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Path from 'path';
import { GraphQLSchema } from 'graphql';
import type { AdminMetaRootVal, KeystoneConfig, AdminFileToWrite } from '../../../types';
import type { AdminMetaRootVal, KeystoneConfig, AdminFileToWrite } from '../../types';
import { appTemplate } from './app';
import { homeTemplate } from './home';
import { listTemplate } from './list';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import weakMemoize from '@emotion/weak-memoize';
import { FragmentDefinitionNode, parse, SelectionSetNode } from 'graphql';
import { FieldController } from '../../../types';
import { FieldController } from '../../types';

function extractRootFields(selectedFields: Set<string>, selectionSet: SelectionSetNode) {
selectionSet.selections.forEach(selection => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,34 @@
/** @jsx jsx */

import 'intersection-observer';
import { RefObject, useEffect, useMemo, useState, createContext, useContext } from 'react';
import { RefObject, useEffect, useMemo, useState, createContext, useContext, useRef } from 'react';

import { jsx } from '@keystone-ui/core';
import { MultiSelect, Select, selectComponents } from '@keystone-ui/fields';
import { validate as validateUUID } from 'uuid';
import { IdFieldConfig, ListMeta } from '../../../../types';
import { gql, TypedDocumentNode, useQuery } from '../../../../admin-ui/apollo';
import {
ApolloClient,
gql,
InMemoryCache,
TypedDocumentNode,
useApolloClient,
useQuery,
} from '../../../../admin-ui/apollo';

function useIntersectionObserver(cb: IntersectionObserverCallback, ref: RefObject<any>) {
const cbRef = useRef(cb);
useEffect(() => {
let observer = new IntersectionObserver(cb, {});
cbRef.current = cb;
});
useEffect(() => {
let observer = new IntersectionObserver((...args) => cbRef.current(...args), {});
let node = ref.current;
if (node !== null) {
observer.observe(node);
return () => observer.unobserve(node);
}
});
}, [ref]);
}

const idValidators = {
Expand All @@ -31,6 +42,20 @@ const idValidators = {
},
};

function useDebouncedValue<T>(value: T, limitMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(() => value);

useEffect(() => {
let id = setTimeout(() => {
setDebouncedValue(() => value);
}, limitMs);
return () => {
clearTimeout(id);
};
}, [value, limitMs]);
return debouncedValue;
}

function useFilter(search: string, list: ListMeta) {
return useMemo(() => {
let conditions: Record<string, any>[] = [];
Expand All @@ -39,12 +64,15 @@ function useFilter(search: string, list: ListMeta) {
const trimmedSearch = search.trim();
const isValidId = idValidators[idFieldKind](trimmedSearch);
if (isValidId) {
conditions.push({ id: trimmedSearch });
conditions.push({ id: { equals: trimmedSearch } });
}
for (const field of Object.values(list.fields)) {
if (field.search !== null) {
conditions.push({
[`${field.path}_contains${field.search === 'insensitive' ? '_i' : ''}`]: trimmedSearch,
[field.path]: {
contains: trimmedSearch,
mode: field.search === 'insensitive' ? 'insensitive' : undefined,
},
});
}
}
Expand Down Expand Up @@ -120,11 +148,43 @@ export const RelationshipSelect = ({
}
`;

const where = useFilter(search, list);
const debouncedSearch = useDebouncedValue(search, 200);
const where = useFilter(debouncedSearch, list);

const link = useApolloClient().link;
// we're using a local apollo client here because writing a global implementation of the typePolicies
// would require making assumptions about how pagination should work which won't always be right
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 { data, error, loading, fetchMore } = useQuery(QUERY, {
fetchPolicy: 'network-only',
variables: { where, take: initialItemsToLoad, skip: 0 },
client: apolloClient,
});

const count = data?.count || 0;
Expand All @@ -144,9 +204,28 @@ export const RelationshipSelect = ({
[count]
);

// we want to avoid fetching more again and `loading` from Apollo
// doesn't seem to become true when fetching more
const [lastFetchMore, setLastFetchMore] = useState<{
where: Record<string, any>;
extraSelection: string;
list: ListMeta;
skip: number;
} | null>(null);

useIntersectionObserver(
([{ isIntersecting }]) => {
if (!loading && isIntersecting && options.length < count) {
const skip = data?.items.length;
if (
!loading &&
skip &&
isIntersecting &&
options.length < count &&
(lastFetchMore?.extraSelection !== extraSelection ||
lastFetchMore?.where !== where ||
lastFetchMore?.list !== list ||
lastFetchMore?.skip !== skip)
) {
const QUERY: TypedDocumentNode<
{ items: { [idField]: string; [labelField]: string | null }[] },
{ where: Record<string, any>; take: number; skip: number }
Expand All @@ -159,21 +238,21 @@ export const RelationshipSelect = ({
}
}
`;
setLastFetchMore({ extraSelection, list, skip, where });
fetchMore({
query: QUERY,
variables: {
where,
take: subsequentItemsToLoad,
skip: data!.items.length,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
...prev,
items: [...prev.items, ...fetchMoreResult.items],
};
skip,
},
});
})
.then(() => {
setLastFetchMore(null);
})
.catch(() => {
setLastFetchMore(null);
});
}
},
{ current: loadingIndicatorElement }
Expand Down

0 comments on commit d1141ea

Please sign in to comment.