Skip to content

Commit f8c9648

Browse files
committed
Fix client side filters not being applied for actionMode
1 parent 00bb413 commit f8c9648

10 files changed

Lines changed: 287 additions & 73 deletions

File tree

examples/usecase-versioning/schema.graphql

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,13 @@ type Mutation {
121121
updatePosts(data: [PostUpdateArgs!]!): [Post]
122122
deletePost(where: PostWhereUniqueInput!): Post
123123
deletePosts(where: [PostWhereUniqueInput!]!): [Post]
124-
publishPost(where: PostWhereUniqueInput!, data: PublishPostData!): Post
124+
publishPost(where: PostWhereUniqueInput!, data: PostUpdateInput!): Post
125125
publishPosts(data: [PublishPostArgs!]!): [Post]
126126
}
127127

128-
input PublishPostData {
129-
version: Int!
130-
}
131-
132128
input PublishPostArgs {
133129
where: PostWhereUniqueInput!
134-
data: PublishPostData!
130+
data: PostUpdateInput!
135131
}
136132

137133
type Query {

examples/usecase-versioning/schema.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,34 @@ export const lists = {
5050
},
5151
}),
5252
},
53+
hooks: {
54+
validate: ({ item, resolvedData, addValidationError }) => {
55+
const resolvedIsPublished = resolvedData?.isPublished ?? item?.isPublished
56+
const resolvedContent = resolvedData?.content ?? item?.content
57+
if (resolvedIsPublished && !resolvedContent) {
58+
addValidationError('Cannot publish a post without content')
59+
}
60+
},
61+
},
5362
actions: {
5463
publish: action({
5564
access: allowAll,
5665
ui: {
5766
label: 'Publish',
58-
itemView: { actionMode: { enabled: true } },
67+
itemView: {
68+
actionMode: {
69+
enabled: { content: { not: { equals: '' } }, isPublished: { equals: false } },
70+
disabled: { isPublished: { equals: false } },
71+
},
72+
},
5973
},
6074
graphql: {
61-
__data: {
62-
version: true,
63-
},
75+
__data: true,
6476
},
6577
resolve: async ({ where, data }, context) => {
6678
return context.sudo().db.Post.updateOne({
6779
where,
68-
data: {
69-
isPublished: true,
70-
version: data.version,
71-
},
80+
data: { ...data, isPublished: true },
7281
})
7382
},
7483
}),

packages/core/src/___internal-do-not-use-will-break-in-patch/admin-ui/pages/ItemPage/index.tsx

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import { useList, useListItem } from '../../../../admin-ui/context'
2929
import {
3030
deserializeItemToValue,
3131
Fields,
32+
resolveConditionalActionMode,
3233
serializeValueToOperationItem,
34+
serializeItemForConditionalFilters,
3335
useHasChanges,
3436
useInvalidFields,
3537
} from '../../../../admin-ui/utils'
@@ -328,17 +330,19 @@ function ItemPage({ listKey }: ItemPageProps) {
328330
setValue(initialValue)
329331
}, [initialValue])
330332

331-
const { actionsInContext, fieldModes, fieldPositions, isRequireds } = useMemo(() => {
332-
const actionModes = Object.fromEntries(
333-
Object.entries(list.actions).map(([k, v]) => [k, v.itemView.actionMode])
334-
)
335-
const fieldModes = Object.fromEntries(
336-
Object.entries(list.fields).map(([k, v]) => [k, v.itemView.fieldMode])
337-
)
338-
const fieldPositions = Object.fromEntries(
333+
const { actionModes, fieldModes, fieldPositions, isRequireds } = useMemo(() => {
334+
const actionModes: Record<
335+
string,
336+
ConditionalFilter<'enabled' | 'disabled' | 'hidden', BaseListTypeInfo>
337+
> = Object.fromEntries(list.actions.map(action => [action.key, action.itemView.actionMode]))
338+
const fieldModes: Record<
339+
string,
340+
ConditionalFilter<'edit' | 'read' | 'hidden', BaseListTypeInfo>
341+
> = Object.fromEntries(Object.entries(list.fields).map(([k, v]) => [k, v.itemView.fieldMode]))
342+
const fieldPositions: Record<string, 'form' | 'sidebar'> = Object.fromEntries(
339343
Object.entries(list.fields).map(([k, v]) => [k, v.itemView.fieldPosition])
340344
)
341-
const isRequireds = Object.fromEntries(
345+
const isRequireds: Record<string, ConditionalFilterCase<BaseListTypeInfo>> = Object.fromEntries(
342346
Object.entries(list.fields).map(([k, v]) => [k, v.itemView.isRequired])
343347
)
344348
for (const field of data?.keystone?.adminMeta?.list?.fields ?? []) {
@@ -359,24 +363,32 @@ function ItemPage({ listKey }: ItemPageProps) {
359363
actionModes[action.key] = action.itemView.actionMode
360364
}
361365

362-
// actions within context of an item
363-
const actionsInContext = list.actions
366+
return {
367+
actionModes,
368+
fieldModes,
369+
fieldPositions,
370+
isRequireds,
371+
}
372+
}, [
373+
data?.keystone?.adminMeta?.list?.actions,
374+
data?.keystone?.adminMeta?.list?.fields,
375+
list.actions,
376+
list.fields,
377+
])
378+
379+
const actionsInContext = useMemo(() => {
380+
if (!value) return []
381+
const serializedValue = serializeItemForConditionalFilters(list.fields, value)
382+
return list.actions
364383
.map(action => ({
365384
...action,
366385
itemView: {
367386
...action.itemView,
368-
actionMode: actionModes[action.key],
387+
actionMode: resolveConditionalActionMode(actionModes[action.key], serializedValue),
369388
},
370389
}))
371390
.filter(action => action.itemView.actionMode !== 'hidden')
372-
373-
return {
374-
actionsInContext,
375-
fieldModes,
376-
fieldPositions,
377-
isRequireds,
378-
}
379-
}, [data?.keystone?.adminMeta, list.fields])
391+
}, [actionModes, list.actions, value])
380392

381393
function onAction(action: ActionMeta, resultId: string | null) {
382394
const { navigation } = action.itemView

packages/core/src/admin-ui/utils/Fields.tsx

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,7 @@ import type {
1717
FieldMeta,
1818
} from '../../types'
1919
import { EmptyState } from '../components/EmptyState'
20-
21-
// with implicit ANDing
22-
function applyFilter<T>(
23-
filter: {
24-
equals?: T
25-
in?: T[]
26-
},
27-
val: T
28-
): boolean {
29-
if (filter.equals !== undefined && val !== filter.equals) return false
30-
if (filter.in !== undefined && !filter.in.includes(val)) return false
31-
return true
32-
}
33-
export function testFilter(
34-
filter: ConditionalFilterCase<BaseListTypeInfo> | undefined,
35-
serialized: Record<string, unknown>
36-
): boolean {
37-
if (filter === undefined) return false
38-
if (typeof filter === 'boolean') return filter
39-
for (const [key, filterOnField] of Object.entries(filter)) {
40-
if (!filterOnField) continue
41-
const serializedValue = serialized[key]
42-
if (!applyFilter(filterOnField, serializedValue)) return false
43-
if (filterOnField.not !== undefined && applyFilter(filterOnField.not, serializedValue)) {
44-
return false
45-
}
46-
}
47-
return true
48-
}
20+
import { serializeItemForConditionalFilters, testFilter } from './conditionalFilters'
4921

5022
export function Fields({
5123
view,
@@ -74,10 +46,7 @@ export function Fields({
7446
}) {
7547
const fieldDomByKey: Record<string, ReactNode> = {}
7648
let focused = false
77-
const serialized: Record<string, unknown> = {}
78-
for (const [fieldKey, field] of Object.entries(fields)) {
79-
Object.assign(serialized, field.controller.serialize(itemValue[fieldKey]))
80-
}
49+
const serialized = serializeItemForConditionalFilters(fields, itemValue)
8150
for (const fieldKey in fields) {
8251
const field = fields[fieldKey]
8352
const fieldPosition = fieldPositions[fieldKey] ?? field.itemView.fieldPosition
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
resolveConditionalActionMode,
3+
serializeItemForConditionalFilters,
4+
} from './conditionalFilters'
5+
6+
test('resolveConditionalActionMode prioritises enabled before disabled and hidden', () => {
7+
expect(
8+
resolveConditionalActionMode(
9+
{
10+
enabled: { status: { equals: 'published' } },
11+
disabled: { status: { equals: 'published' } },
12+
hidden: true,
13+
},
14+
{ status: 'published' }
15+
)
16+
).toBe('enabled')
17+
})
18+
19+
test('resolveConditionalActionMode returns disabled when enabled does not match', () => {
20+
expect(
21+
resolveConditionalActionMode(
22+
{
23+
enabled: { status: { equals: 'published' } },
24+
disabled: { status: { equals: 'archived' } },
25+
},
26+
{ status: 'archived' }
27+
)
28+
).toBe('disabled')
29+
})
30+
31+
test('resolveConditionalActionMode falls through to hidden when only hidden matches', () => {
32+
expect(
33+
resolveConditionalActionMode(
34+
{
35+
enabled: { status: { equals: 'published' } },
36+
disabled: { status: { equals: 'archived' } },
37+
hidden: { status: { equals: 'draft' } },
38+
},
39+
{ status: 'draft' }
40+
)
41+
).toBe('hidden')
42+
})
43+
44+
test('serialised values reflect unsaved edits when resolving action modes', () => {
45+
const fields = {
46+
status: {
47+
controller: {
48+
serialize(value: unknown) {
49+
return { status: value }
50+
},
51+
},
52+
},
53+
}
54+
55+
const initialSerialized = serializeItemForConditionalFilters(fields, { status: 'draft' })
56+
expect(
57+
resolveConditionalActionMode(
58+
{
59+
enabled: { status: { equals: 'published' } },
60+
},
61+
initialSerialized
62+
)
63+
).toBe('hidden')
64+
65+
const editedSerialized = serializeItemForConditionalFilters(fields, {
66+
status: 'published',
67+
})
68+
expect(
69+
resolveConditionalActionMode(
70+
{
71+
enabled: { status: { equals: 'published' } },
72+
},
73+
editedSerialized
74+
)
75+
).toBe('enabled')
76+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { BaseListTypeInfo, ConditionalFilter, ConditionalFilterCase } from '../../types'
2+
3+
type SerializedItem = Record<string, unknown>
4+
type SerializableFields = Record<
5+
string,
6+
{
7+
controller: {
8+
serialize(value: unknown): Record<string, unknown>
9+
}
10+
}
11+
>
12+
13+
// with implicit ANDing
14+
function applyFilter<T>(
15+
filter: {
16+
equals?: T
17+
in?: T[]
18+
},
19+
val: T
20+
): boolean {
21+
if (filter.equals !== undefined && val !== filter.equals) return false
22+
if (filter.in !== undefined && !filter.in.includes(val)) return false
23+
return true
24+
}
25+
26+
export function testFilter(
27+
filter: ConditionalFilterCase<BaseListTypeInfo> | undefined,
28+
serialized: SerializedItem
29+
): boolean {
30+
if (filter === undefined) return false
31+
if (typeof filter === 'boolean') return filter
32+
for (const [key, filterOnField] of Object.entries(filter)) {
33+
if (!filterOnField) continue
34+
const serializedValue = serialized[key]
35+
if (!applyFilter(filterOnField, serializedValue)) return false
36+
if (filterOnField.not !== undefined && applyFilter(filterOnField.not, serializedValue)) {
37+
return false
38+
}
39+
}
40+
return true
41+
}
42+
43+
export function serializeItemForConditionalFilters(
44+
fields: SerializableFields,
45+
itemValue: Record<string, unknown>
46+
): SerializedItem {
47+
const serialized: SerializedItem = {}
48+
for (const [fieldKey, field] of Object.entries(fields)) {
49+
Object.assign(serialized, field.controller.serialize(itemValue[fieldKey]))
50+
}
51+
return serialized
52+
}
53+
54+
export function resolveConditionalActionMode(
55+
actionMode: ConditionalFilter<'enabled' | 'disabled' | 'hidden', BaseListTypeInfo>,
56+
serialized: SerializedItem
57+
): 'enabled' | 'disabled' | 'hidden' {
58+
if (typeof actionMode === 'string') return actionMode
59+
if (testFilter(actionMode.enabled, serialized)) return 'enabled'
60+
if (testFilter(actionMode.disabled, serialized)) return 'disabled'
61+
return 'hidden'
62+
}

packages/core/src/admin-ui/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './Fields'
2+
export * from './conditionalFilters'
23
export * from './utils'
34
export * from './useCreateItem'
45

packages/core/src/admin-ui/utils/utils.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
FieldController,
99
FieldMeta,
1010
} from '../../types'
11-
import { testFilter } from './Fields'
11+
import { serializeItemForConditionalFilters, testFilter } from './conditionalFilters'
1212

1313
function extractRootFields(selectedFields: Set<string>, selectionSet: SelectionSetNode) {
1414
selectionSet.selections.forEach(selection => {
@@ -37,10 +37,7 @@ export function useInvalidFields(
3737
): ReadonlySet<string> {
3838
return useMemo(() => {
3939
const invalidFields = new Set<string>()
40-
const serialized: Record<string, unknown> = {}
41-
for (const [fieldKey, field] of Object.entries(fields)) {
42-
Object.assign(serialized, field.controller.serialize(item[fieldKey]))
43-
}
40+
const serialized = serializeItemForConditionalFilters(fields, item)
4441

4542
for (const fieldKey in item) {
4643
const validateFn = fields[fieldKey]?.controller?.validate

packages/core/src/lib/admin-meta-graphql.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,10 @@ const KeystoneAdminUIListMeta = g.object<ListMetaSource>()({
327327
type: g.nonNull(g.list(g.nonNull(KeystoneAdminUIFieldMeta))),
328328
}),
329329
groups: g.field({ type: g.nonNull(g.list(g.nonNull(KeystoneAdminUIFieldGroupMeta))) }),
330-
actions: g.field({ type: g.nonNull(g.list(g.nonNull(KeystoneAdminUIActionMeta))) }),
330+
actions: g.field({
331+
resolve: ({ actions, item }) => actions.map(action => ({ ...action, item })),
332+
type: g.nonNull(g.list(g.nonNull(KeystoneAdminUIActionMeta))),
333+
}),
331334
graphql: g.field({ type: g.nonNull(KeystoneAdminUIGraphQL) }),
332335

333336
pageSize: g.field({ type: g.nonNull(g.Int) }),

0 commit comments

Comments
 (0)