Fix insights filtering (#16139)

* Parse string filter as JSON

* Parse content as JSON when toggling raw editor

* Refactor missing Dashboard type

* Convert filter to gql format

* Disable alias field filter selection in insights

* Use parseJSON util

* Refactor to allow selectivity of relational field for GraphQL filters

* Emit variables

* Skip emitting invalid JSON

* Add unit tests
This commit is contained in:
ian
2023-01-19 03:31:59 +08:00
committed by GitHub
parent d42de82bcd
commit 025bb7c053
12 changed files with 211 additions and 15 deletions

View File

@@ -1,7 +1,7 @@
<template>
<v-list-group
v-if="field.children || supportedFunctions.length > 0"
:clickable="!field.disabled"
:clickable="!field.disabled && (relationalFieldSelectable || !field.relatedCollection)"
:value="field.path"
@click="$emit('add', field.key)"
>
@@ -39,6 +39,7 @@
:field="childField"
:search="search"
:include-functions="includeFunctions"
:relational-field-selectable="relationalFieldSelectable"
@add="$emit('add', $event)"
/>
</v-list-group>
@@ -72,11 +73,13 @@ interface Props {
field: FieldInfo;
search?: string;
includeFunctions?: boolean;
relationalFieldSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
search: undefined,
includeFunctions: false,
relationalFieldSelectable: true,
});
defineEmits(['add']);

View File

@@ -17,6 +17,7 @@
:field="fieldNode"
:search="search"
:include-functions="includeFunctions"
:relational-field-selectable="relationalFieldSelectable"
@add="$emit('select-field', $event)"
/>
</v-list>
@@ -37,6 +38,7 @@ interface Props {
disabledFields?: string[];
includeFunctions?: boolean;
includeRelations?: boolean;
relationalFieldSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -44,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
disabledFields: () => [],
includeFunctions: false,
includeRelations: true,
relationalFieldSelectable: true,
});
defineEmits(['select-field']);

View File

@@ -158,7 +158,8 @@ export default defineComponent({
if (
typeof val === 'string' &&
['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'].some((prefix) => val.startsWith(prefix))
(['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'].some((prefix) => val.startsWith(prefix)) ||
/^{{\s*?\S+?\s*?}}$/.test(val))
) {
return emit('input', val);
}

View File

@@ -29,6 +29,7 @@
:field="field"
include-functions
:include-relations="includeRelations"
:relational-field-selectable="relationalFieldSelectable"
@select-field="updateField(index, $event)"
/>
</v-menu>
@@ -142,6 +143,7 @@ interface Props {
inline?: boolean;
includeValidation?: boolean;
includeRelations?: boolean;
relationalFieldSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -150,6 +152,7 @@ const props = withDefaults(defineProps<Props>(), {
inline: false,
includeValidation: false,
includeRelations: true,
relationalFieldSelectable: true,
});
const emit = defineEmits(['remove-node', 'update:filter', 'change']);

View File

@@ -20,6 +20,7 @@
:depth="1"
:include-validation="includeValidation"
:include-relations="includeRelations"
:relational-field-selectable="relationalFieldSelectable"
@remove-node="removeNode($event)"
@change="emitValue"
/>
@@ -44,6 +45,7 @@
:collection="collection"
include-functions
:include-relations="includeRelations"
:relational-field-selectable="relationalFieldSelectable"
@select-field="addNode($event)"
>
<template #prepend>
@@ -83,7 +85,12 @@
import { useFieldsStore } from '@/stores/fields';
import { useRelationsStore } from '@/stores/relations';
import { Filter, Type, FieldFunction } from '@directus/shared/types';
import { getFilterOperatorsForType, getOutputTypeForFunction, parseFilterFunctionPath } from '@directus/shared/utils';
import {
getFilterOperatorsForType,
getOutputTypeForFunction,
parseFilterFunctionPath,
parseJSON,
} from '@directus/shared/utils';
import { cloneDeep, get, isEmpty, set } from 'lodash';
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -91,7 +98,7 @@ import Nodes from './nodes.vue';
import { getNodeName } from './utils';
interface Props {
value?: Record<string, any>;
value?: Record<string, any> | string;
disabled?: boolean;
collectionName?: string;
collectionField?: string;
@@ -100,6 +107,7 @@ interface Props {
inline?: boolean;
includeValidation?: boolean;
includeRelations?: boolean;
relationalFieldSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -112,6 +120,7 @@ const props = withDefaults(defineProps<Props>(), {
inline: false,
includeValidation: false,
includeRelations: true,
relationalFieldSelectable: true,
});
const emit = defineEmits(['input']);
@@ -132,14 +141,16 @@ const relationsStore = useRelationsStore();
const innerValue = computed<Filter[]>({
get() {
if (!props.value || isEmpty(props.value)) return [];
const filterValue = typeof props.value === 'string' ? parseJSON(props.value) : props.value;
const name = getNodeName(props.value);
if (!filterValue || isEmpty(filterValue)) return [];
const name = getNodeName(filterValue);
if (name === '_and') {
return cloneDeep(props.value['_and']);
return cloneDeep(filterValue['_and']);
} else {
return cloneDeep([props.value]);
return cloneDeep([filterValue]);
}
},
set(newVal) {

View File

@@ -11,10 +11,11 @@ import 'codemirror/addon/mode/simple';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { mustacheMode } from './mustacheMode';
import { parseJSON } from '@directus/shared/utils';
const props = withDefaults(
defineProps<{
value?: string;
value?: string | object;
autofocus?: boolean;
disabled?: boolean;
type?: string;
@@ -85,7 +86,15 @@ onMounted(async () => {
codemirror.on('change', (doc, { origin }) => {
if (origin === 'setValue') return;
const content = doc.getValue();
emit('input', content !== '' ? content : null);
if (typeof props.value === 'object') {
try {
emit('input', content !== '' ? parseJSON(content) : null);
} catch {
// Skip emitting invalid JSON
}
} else {
emit('input', content !== '' ? content : null);
}
});
}
});

View File

@@ -139,6 +139,7 @@ export default definePanel({
interface: 'system-filter',
options: {
collectionField: 'collection',
relationalFieldSelectable: false,
},
},
},

View File

@@ -180,6 +180,7 @@ export default definePanel({
interface: 'system-filter',
options: {
collectionField: 'collection',
relationalFieldSelectable: false,
},
},
},

View File

@@ -100,6 +100,7 @@ export default definePanel({
interface: 'system-filter',
options: {
collectionField: 'collection',
relationalFieldSelectable: false,
},
},
},

View File

@@ -378,6 +378,7 @@ export default definePanel({
interface: 'system-filter',
options: {
collectionField: 'collection',
relationalFieldSelectable: false,
},
},
},

View File

@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { formatQuery } from '@/utils/query-to-gql-string';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { Query } from '@directus/shared/types';
const collectionName = 'users';
const primaryKeyField = 'id';
const key = 'query_abcde';
beforeEach(() => {
setActivePinia(
createTestingPinia({
createSpy: () => (collection) => {
return { collection, field: primaryKeyField };
},
})
);
});
describe('Empty query returns the primary key', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = {};
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: collectionName,
__args: query,
[primaryKeyField]: true,
});
});
});
describe('Defined fields are requested', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = { fields: ['aaa', 'bbb', 'ccc'] };
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: collectionName,
__args: {},
aaa: true,
bbb: true,
ccc: true,
});
});
});
describe('Aggregation query without group', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = { aggregate: { count: ['aaa'], sum: ['bbb', 'ccc'] } };
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: `${collectionName}_aggregated`,
__args: {},
count: {
aaa: true,
},
sum: {
bbb: true,
ccc: true,
},
});
});
});
describe('Aggregation query with group', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = { aggregate: { count: ['aaa'], sum: ['bbb', 'ccc'] }, group: ['ddd', 'eee'] };
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: `${collectionName}_aggregated`,
__args: {
groupBy: ['ddd', 'eee'],
},
count: {
aaa: true,
},
sum: {
bbb: true,
ccc: true,
},
group: true,
});
});
});
describe('Filter query without functions', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = { filter: { _and: [{ aaa: { _eq: '111' } }, { bbb: { ccc: { _eq: '222' } } }] } };
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: collectionName,
__args: {
filter: { _and: [{ aaa: { _eq: '111' } }, { bbb: { ccc: { _eq: '222' } } }] },
},
[primaryKeyField]: true,
});
});
});
describe('Filter query with functions', () => {
test.each([true, false])(`System collection: %o`, (isSystemCollection) => {
const query: Query = {
filter: { _and: [{ 'count(aaa)': { _eq: '111' } }, { bbb: { 'sum(ccc)': { _eq: '222' } } }] },
};
const collection = isSystemCollection ? `directus_${collectionName}` : collectionName;
const formatted = formatQuery({ collection, key, query });
expect(formatted).toStrictEqual({
__aliasFor: collectionName,
__args: {
filter: { _and: [{ aaa_func: { count: { _eq: '111' } } }, { bbb: { ccc_func: { sum: { _eq: '222' } } } }] },
},
[primaryKeyField]: true,
});
});
});

View File

@@ -1,8 +1,9 @@
import { useFieldsStore } from '@/stores/fields';
import { Query } from '@directus/shared/types';
import { toArray } from '@directus/shared/utils';
import { Filter, Query } from '@directus/shared/types';
import { parseJSON, toArray } from '@directus/shared/utils';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { isEmpty, pick, set, omitBy, isUndefined } from 'lodash';
import { isEmpty, pick, set, omitBy, isUndefined, transform } from 'lodash';
import { extractFieldFromFunction } from './extract-field-from-function';
type QueryInfo = { collection: string; key: string; query: Query };
@@ -61,8 +62,8 @@ export function formatQuery({ collection, query }: QueryInfo): Record<string, an
if (query.filter) {
try {
const json = String(query.filter);
formattedQuery.__args.filter = JSON.parse(json);
const filterValue = typeof query.filter === 'object' ? query.filter : parseJSON(String(query.filter));
formattedQuery.__args.filter = replaceFuncs(filterValue);
} catch {
// Keep current value there
}
@@ -70,3 +71,30 @@ export function formatQuery({ collection, query }: QueryInfo): Record<string, an
return formattedQuery;
}
/**
* Replace functions from Directus-Filter format to GraphQL format
*/
function replaceFuncs(filter?: Filter | null): null | undefined | Filter {
if (!filter) return filter;
return replaceFuncDeep(filter);
function replaceFuncDeep(filter: Record<string, any>) {
return transform(filter, (result: Record<string, any>, value, key) => {
if (typeof key === 'string' && key.includes('(') && key.includes(')')) {
const { fn, field } = extractFieldFromFunction(key);
if (fn) {
result[`${field}_func`] = {
[fn]: value,
};
} else {
result[key] = value;
}
} else {
result[key] = value?.constructor === Object || value?.constructor === Array ? replaceFuncDeep(value) : value;
}
});
}
}