Skip to content

Commit

Permalink
fix(structure): resolve static types in document list filters (#6439)
Browse files Browse the repository at this point in the history
  • Loading branch information
ricokahler authored Apr 22, 2024
1 parent c9b1ebb commit 048ce0b
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 43 deletions.
2 changes: 0 additions & 2 deletions dev/test-studio/structure/resolveStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ export const structure: StructureResolver = (S, {schema, documentStore, i18n}) =
id: 'authors-and-books',
title: 'Authors & Books',
options: {
apiVersion: '2023-07-28',
filter: '_type == "author" || _type == "book"',
},
}),
Expand Down Expand Up @@ -341,7 +340,6 @@ export const structure: StructureResolver = (S, {schema, documentStore, i18n}) =
child: () =>
S.documentTypeList('author')
.title('Developers')
.apiVersion('2023-07-27')
.filter('_type == $type && role == $role')
.params({type: 'author', role: 'developer'})
.initialValueTemplates(S.initialValueTemplateItem('author-developer')),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ import {type BaseStructureToolPaneProps} from '../types'
import {DEFAULT_ORDERING, EMPTY_RECORD} from './constants'
import {DocumentListPaneContent} from './DocumentListPaneContent'
import {DocumentListPaneHeader} from './DocumentListPaneHeader'
import {
applyOrderingFunctions,
getTypeNameFromSingleTypeFilter,
isSimpleTypeFilter,
} from './helpers'
import {applyOrderingFunctions, findStaticTypesInFilter} from './helpers'
import {type LoadingVariant, type SortOrder} from './types'
import {useDocumentList} from './useDocumentList'

Expand Down Expand Up @@ -106,7 +102,12 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
const {apiVersion, defaultOrdering = EMPTY_ARRAY, filter} = options
const params = useShallowUnique(options.params || EMPTY_RECORD)
const sourceName = pane.source
const typeName = useMemo(() => getTypeNameFromSingleTypeFilter(filter, params), [filter, params])
const typeName = useMemo(() => {
const staticTypes = findStaticTypesInFilter(filter, params)
if (staticTypes?.length === 1) return staticTypes[0]
return null
}, [filter, params])

const showIcons = displayOptions?.showIcons !== false
const [layout, setLayout] = useStructureToolSetting<GeneralPreviewLayoutKey>(
'layout',
Expand Down Expand Up @@ -143,7 +144,6 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
: sortOrderRaw

const sortOrder = useUnique(sortWithOrderingFn)
const filterIsSimpleTypeConstraint = isSimpleTypeFilter(filter)

const {
error,
Expand Down Expand Up @@ -276,7 +276,7 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
<DocumentListPaneContent
childItemId={childItemId}
error={error}
filterIsSimpleTypeConstraint={filterIsSimpleTypeConstraint}
filterIsSimpleTypeConstraint={!!typeName}
hasMaxItems={hasMaxItems}
hasSearchQuery={Boolean(searchQuery)}
isActive={isActive}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {describe, expect, test} from '@jest/globals'
import {Schema} from '@sanity/schema'
import {type ObjectSchemaType} from '@sanity/types'

import {applyOrderingFunctions, fieldExtendsType} from '../helpers'
import {applyOrderingFunctions, fieldExtendsType, findStaticTypesInFilter} from '../helpers'

const mockSchema = Schema.compile({
name: 'default',
Expand Down Expand Up @@ -169,3 +169,29 @@ describe('fieldExtendsType()', () => {
})
/* eslint-enable @typescript-eslint/no-non-null-assertion */
})

describe('findStaticTypesInFilter()', () => {
test('returns the types from a simple filter clause', () => {
expect(findStaticTypesInFilter('_type == "a"')).toEqual(['a'])
})

test('returns multiple types from a simple filter clause', () => {
expect(findStaticTypesInFilter('_type == "a" || "b" == _type')).toEqual(['a', 'b'])
})

test('returns multiple types from `in` expressions', () => {
expect(findStaticTypesInFilter('_type in ["a", "b"]')).toEqual(['a', 'b'])
})

test('returns the types in `&&` expressions', () => {
expect(findStaticTypesInFilter('_type in ["a", "b"] && isActive')).toEqual(['a', 'b'])
})

test('returns null if the types cannot be statically determined', () => {
expect(findStaticTypesInFilter('_type == "a" || isActive')).toEqual(null)
})

test('works with parameters', () => {
expect(findStaticTypesInFilter('_type in ["a", $b]', {b: 'b'})).toEqual(['a', 'b'])
})
})
142 changes: 114 additions & 28 deletions packages/sanity/src/structure/panes/documentList/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type SchemaType,
} from '@sanity/types'
import * as PathUtils from '@sanity/util/paths'
import {type ExprNode, parse} from 'groq-js'
import {collate, getPublishedId} from 'sanity'

import {type DocumentListPaneItem, type SortOrder} from './types'
Expand All @@ -28,34 +29,6 @@ export function removePublishedWithDrafts(documents: SanityDocumentLike[]): Docu
}) as any
}

const RE_TYPE_NAME_IN_FILTER =
/\b_type\s*==\s*(['"].*?['"]|\$.*?(?:\s|$))|\B(['"].*?['"]|\$.*?(?:\s|$))\s*==\s*_type\b/
export function getTypeNameFromSingleTypeFilter(
filter: string,
params: Record<string, unknown> = {},
): string | null {
const matches = filter.match(RE_TYPE_NAME_IN_FILTER)

if (!matches) {
return null
}

const match = (matches[1] || matches[2]).trim().replace(/^["']|["']$/g, '')

if (match[0] === '$') {
const k = match.slice(1)
const v = params[k]

return typeof v === 'string' ? v : null
}

return match
}

export function isSimpleTypeFilter(filter: string): boolean {
return /^_type\s*==\s*['"$]\w+['"]?\s*$/.test(filter.trim())
}

export function applyOrderingFunctions(order: SortOrder, schemaType: ObjectSchemaType): SortOrder {
const orderBy = order.by.map((by) => {
// Skip those that already have a mapper
Expand Down Expand Up @@ -151,3 +124,116 @@ export function fieldExtendsType(field: ObjectField | ObjectFieldType, ofType: s

return false
}

/**
* Recursively extract static `_type`s from GROQ filter expressions. If the
* types can't be statically determined then it will return `null`.
*/
// eslint-disable-next-line complexity
function findTypes(node: ExprNode): Set<string> | null {
switch (node.type) {
case 'OpCall': {
const {left, right} = node

switch (node.op) {
// e.g. `a == b`
case '==': {
// e.g. `_type == 'value'`
if (left.type === 'AccessAttribute' && left.name === '_type' && !left.base) {
if (right.type !== 'Value' || typeof right.value !== 'string') return null
return new Set([right.value])
}

// e.g. `'value' == _type`
if (right.type === 'AccessAttribute' && right.name === '_type' && !right.base) {
if (left.type !== 'Value' || typeof left.value !== 'string') return null
return new Set([left.value])
}

// otherwise, we can't determine the types statically
return null
}

// e.g. `a in b`
case 'in': {
// if `_type` is not on the left hand side of `in` then it can't be determined
if (left.type !== 'AccessAttribute' || left.name !== '_type' || left.base) return null
// if the right hand side is not an array then the types can't be determined
if (right.type !== 'Array') return null

const types = new Set<string>()
// iterate through all the types
for (const element of right.elements) {
// if we find a splat, then early return, we can't determine the types
if (element.isSplat) return null
// if the array element is not just a simple value, then early return
if (element.value.type !== 'Value') return null
// if the array element value is not a string, then early return
if (typeof element.value.value !== 'string') return null
// otherwise add the element value to the set of types
types.add(element.value.value)
}

// if there were any elements in the types set, return it
if (types.size) return types
// otherwise, the set of types cannot be determined
return null
}

default: {
return null
}
}
}

// groups can just be unwrapped, the AST preserves the order
case 'Group': {
return findTypes(node.base)
}

// e.g. `_type == 'a' || _type == 'b'`
// with Or nodes, if we can't determine the types for either the left or
// right hand side then we can't determine the types for any
// e.g. `_type == 'a' || isActive`
// — can't determine types because `isActive` could be true on another types
case 'Or': {
const left = findTypes(node.left)
if (!left) return null

const right = findTypes(node.right)
if (!right) return null

return new Set([...left, ...right])
}

// e.g. `_type == 'a' && isActive`
// with And nodes, we can determine the types as long as we can determine
// the types for one side. We can't determine the types if both are `null`.
case 'And': {
const left = findTypes(node.left)
const right = findTypes(node.right)

if (!left && !right) return null
return new Set([...(left || []), ...(right || [])])
}

default: {
return null
}
}
}

export function findStaticTypesInFilter(
filter: string,
params: Record<string, unknown> = {},
): string[] | null {
try {
const types = findTypes(parse(filter, {params}))
if (!types) return null

return Array.from(types).sort()
} catch {
// if we couldn't parse the filter, just return `null`
return null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface ListenQueryOptions {
schema: Schema
searchQuery: string
sort: SortOrder
staticTypeNames?: string[]
staticTypeNames?: string[] | null
maxFieldDepth?: number
enableLegacySearch?: boolean
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from 'sanity'

import {DEFAULT_ORDERING, FULL_LIST_LIMIT, PARTIAL_PAGE_LIMIT} from './constants'
import {getTypeNameFromSingleTypeFilter, removePublishedWithDrafts} from './helpers'
import {findStaticTypesInFilter, removePublishedWithDrafts} from './helpers'
import {listenSearchQuery} from './listenSearchQuery'
import {type DocumentListPaneItem, type QueryResult, type SortOrder} from './types'

Expand Down Expand Up @@ -82,7 +82,7 @@ export function useDocumentList(opts: UseDocumentListOpts): DocumentListState {

// Get the type name from the filter, if it is a simple type filter.
const typeNameFromFilter = useMemo(
() => getTypeNameFromSingleTypeFilter(filter, paramsProp),
() => findStaticTypesInFilter(filter, paramsProp),
[filter, paramsProp],
)

Expand Down Expand Up @@ -158,7 +158,7 @@ export function useDocumentList(opts: UseDocumentListOpts): DocumentListState {
schema,
searchQuery: searchQuery || '',
sort,
staticTypeNames: typeNameFromFilter ? [typeNameFromFilter] : undefined,
staticTypeNames: typeNameFromFilter,
maxFieldDepth,
enableLegacySearch,
}).pipe(
Expand Down

0 comments on commit 048ce0b

Please sign in to comment.