diff --git a/.changeset/thin-eels-train.md b/.changeset/thin-eels-train.md new file mode 100644 index 00000000000..cb24e6af4a6 --- /dev/null +++ b/.changeset/thin-eels-train.md @@ -0,0 +1,24 @@ +--- +'@keystonejs/api-tests': minor +'@keystonejs/keystone': minor +--- + +Exposes `dataType` and `options` meta data for `Select` fields: + ```graphql + query { + _UserMeta { + schema { + fields { + name + type + ...on _SelectMeta { + dataType + options { + label + } + } + } + } + } + } + ``` diff --git a/api-tests/fields.test.js b/api-tests/fields.test.js index 2f0a3c5c1a6..7fc34a6a5cb 100644 --- a/api-tests/fields.test.js +++ b/api-tests/fields.test.js @@ -46,6 +46,14 @@ describe('Fields', () => { test.todo('CRUD operations - tests missing'); } + if (mod.metaTests) { + describe(`Meta query`, () => { + mod.metaTests(keystoneTestWrapper); + }); + } else { + test.todo('Meta query - tests missing'); + } + if (mod.filterTests) { describe(`Filtering`, () => { mod.filterTests(keystoneTestWrapper); diff --git a/packages/fields/src/Implementation.js b/packages/fields/src/Implementation.js index a3f78e97be2..b56eea6746c 100644 --- a/packages/fields/src/Implementation.js +++ b/packages/fields/src/Implementation.js @@ -5,7 +5,7 @@ class Field { constructor( path, { hooks = {}, isRequired, defaultValue, access, label, schemaDoc, adminDoc, ...config }, - { getListByKey, listKey, listAdapter, fieldAdapterClass, defaultAccess, schemaNames } + { getListByKey, listKey, listAdapter, fieldAdapterClass, defaultAccess, schemaNames, type } ) { this.path = path; this.isPrimaryKey = path === 'id'; @@ -27,6 +27,8 @@ class Field { getListByKey, { ...config } ); + this.type = type; + this.gqlMetaType = `_${type.type}Meta`; // Should be overwritten by types that implement a Relationship interface this.isRelationship = false; @@ -83,6 +85,24 @@ class Field { return {}; } + getGqlMetaTypes({ interfaceType }) { + return [ + ` + type ${this.gqlMetaType} implements ${interfaceType} { + name: String + type: String + } + `, + ]; + } + gqlMetaQueryResolver() { + return { + __typename: this.gqlMetaType, + name: this.path, + type: this.type.type, + }; + } + /* * @param {Object} data * @param {Object} data.resolvedData The incoming item for the mutation with diff --git a/packages/fields/src/types/OEmbed/Implementation.test.js b/packages/fields/src/types/OEmbed/Implementation.test.js index d9d7f4ef96c..b1963189587 100644 --- a/packages/fields/src/types/OEmbed/Implementation.test.js +++ b/packages/fields/src/types/OEmbed/Implementation.test.js @@ -6,7 +6,11 @@ const newOEmbed = ({ config = {} } = {}) => { return new OEmbed( path, { access: true, ...config }, - { listAdapter: { newFieldAdapter: () => {} }, schemaNames: ['public'] } + { + listAdapter: { newFieldAdapter: () => {} }, + schemaNames: ['public'], + type: { type: 'OEmebed' }, + } ); }; diff --git a/packages/fields/src/types/Relationship/tests/implementation.test.js b/packages/fields/src/types/Relationship/tests/implementation.test.js index 8b81b39e503..0944b30b3fc 100644 --- a/packages/fields/src/types/Relationship/tests/implementation.test.js +++ b/packages/fields/src/types/Relationship/tests/implementation.test.js @@ -56,6 +56,7 @@ function createRelationship({ path, config = {}, getListByKey = () => new MockLi fieldAdapterClass: MockFieldAdapter, defaultAccess: true, schemaNames: ['public'], + type: { type: 'Relationship' }, }); } diff --git a/packages/fields/src/types/Select/Implementation.js b/packages/fields/src/types/Select/Implementation.js index 40a2d76b333..3d48d5d5a54 100644 --- a/packages/fields/src/types/Select/Implementation.js +++ b/packages/fields/src/types/Select/Implementation.js @@ -2,6 +2,7 @@ import inflection from 'inflection'; import { Implementation } from '../../Implementation'; import { MongooseFieldAdapter } from '@keystonejs/adapter-mongoose'; import { KnexFieldAdapter } from '@keystonejs/adapter-knex'; +import { upcase } from '@keystonejs/utils'; function initOptions(options) { let optionsArray = options; @@ -15,6 +16,14 @@ function initOptions(options) { const VALID_DATA_TYPES = ['enum', 'string', 'integer']; const DOCS_URL = 'https://keystonejs.com/keystonejs/fields/src/types/select/'; +function dataTypeToGqlMetaType({ gqlMetaType, dataType }) { + return `${gqlMetaType}Type${upcase(dataType)}`; +} + +function dataTypeToGqlValueField({ dataType }) { + return `${dataType.toLowerCase()}Value`; +} + function validateOptions({ options, dataType, listKey, path }) { if (!VALID_DATA_TYPES.includes(dataType)) { throw new Error( @@ -97,6 +106,65 @@ export class Select extends Implementation { ] : []; } + getGqlMetaTypes({ interfaceType }) { + const { gqlMetaType } = this; + const dataTypeEnum = `${this.gqlMetaType}DataType`; + const optionsType = `${this.gqlMetaType}Options`; + const dataTypeToValueType = { + enum: 'String', + string: 'String', + integer: 'Int', + }; + return [ + ` + enum ${dataTypeEnum} { + ${VALID_DATA_TYPES.map(type => type.toUpperCase()).join('\n')} + } + `, + ` + interface ${optionsType} { + label: String + } + `, + ...VALID_DATA_TYPES.map(dataType => { + return ` + type ${dataTypeToGqlMetaType({ gqlMetaType, dataType })} implements ${optionsType} { + label: String + ${dataTypeToGqlValueField({ dataType })}: ${dataTypeToValueType[dataType]} + } + `; + }), + ` + type ${this.gqlMetaType} implements ${interfaceType} { + name: String + type: String + dataType: ${dataTypeEnum} + options: [${optionsType}] + } + `, + ]; + } + gqlMetaQueryResolver() { + const { gqlMetaType, dataType } = this; + return { + ...super.gqlMetaQueryResolver(), + dataType: this.dataType.toUpperCase(), + options: this.options.map(({ value, label }) => ({ + __typename: dataTypeToGqlMetaType({ gqlMetaType, dataType }), + [dataTypeToGqlValueField({ dataType })]: value, + label, + })), + }; + } + gqlAuxFieldResolvers() { + return { + // We have to implement this to avoid Apollo throwing a warning about it + // missing (even though it works just fine without it, grrrr) + [`${this.gqlMetaType}Options`]: { + __resolveType: ({ __typename }) => __typename, + }, + }; + } extendAdminMeta(meta) { const { options, dataType } = this; diff --git a/packages/fields/src/types/Select/test-fixtures.js b/packages/fields/src/types/Select/test-fixtures.js index 2e540466e63..2eff198d4cf 100644 --- a/packages/fields/src/types/Select/test-fixtures.js +++ b/packages/fields/src/types/Select/test-fixtures.js @@ -1,4 +1,4 @@ -import { matchFilter } from '@keystonejs/test-utils'; +import { matchFilter, graphqlRequest } from '@keystonejs/test-utils'; import Select from './'; import Text from '../Text'; @@ -264,3 +264,93 @@ export const filterTests = withKeystone => { ) ); }; + +export const metaTests = withKeystone => { + const queryListMeta = async ({ keystone, listName }) => { + const list = keystone.getListByKey(listName); + const { listMetaName } = list.gqlNames; + const { data: { [listMetaName]: items } = {}, errors } = await graphqlRequest({ + keystone, + query: `query { + ${listMetaName} { + schema { + fields { + __typename + name + type + ...on _SelectMeta { + dataType + options { + label + ...on _SelectMetaTypeEnum { + enumValue + } + ...on _SelectMetaTypeString { + stringValue + } + ...on _SelectMetaTypeInteger { + integerValue + } + } + } + } + } + } + }`, + }); + expect(errors).toBe(undefined); + return items; + }; + + test( + 'includes all expected fields', + withKeystone(async ({ keystone, listName }) => { + const items = await queryListMeta({ keystone, listName }); + expect(items).toEqual({ + schema: { + fields: [ + { + __typename: '_TextMeta', + name: 'name', + type: 'Text', + }, + { + __typename: '_SelectMeta', + name: 'company', + type: 'Select', + dataType: 'ENUM', + options: [ + { label: 'Thinkmill', enumValue: 'thinkmill' }, + { label: 'Atlassian', enumValue: 'atlassian' }, + { label: 'Thomas Walker Gelato', enumValue: 'gelato' }, + { label: 'Cete, or Seat, or Attend ¯\\_(ツ)_/¯', enumValue: 'cete' }, + ], + }, + { + __typename: '_SelectMeta', + name: 'selectString', + type: 'Select', + dataType: 'STRING', + options: [ + { label: 'A string', stringValue: 'a string' }, + { label: '1number', stringValue: '1number' }, + { label: '@¯\\_(ツ)_/¯', stringValue: '@¯\\_(ツ)_/¯' }, + ], + }, + { + __typename: '_SelectMeta', + name: 'selectNumber', + type: 'Select', + dataType: 'INTEGER', + options: [ + { label: 'One', integerValue: 1 }, + { label: 'Two', integerValue: 2 }, + { label: 'Three', integerValue: 3 }, + ], + }, + ], + }, + }); + }) + ); +}; diff --git a/packages/fields/tests/Implementation.test.js b/packages/fields/tests/Implementation.test.js index e84f7356110..29d5d455584 100644 --- a/packages/fields/tests/Implementation.test.js +++ b/packages/fields/tests/Implementation.test.js @@ -8,6 +8,7 @@ const args = { }, defaultAccess: true, schemaNames: ['public'], + type: { type: 'AType' }, }; describe('new Implementation()', () => { @@ -23,6 +24,7 @@ describe('new Implementation()', () => { }, defaultAccess: true, schemaNames: ['public'], + type: { type: 'AType' }, } ); expect(impl).not.toBeNull(); diff --git a/packages/keystone/lib/List/index.js b/packages/keystone/lib/List/index.js index 3a21245323a..f0bf3d8970b 100644 --- a/packages/keystone/lib/List/index.js +++ b/packages/keystone/lib/List/index.js @@ -314,6 +314,7 @@ module.exports = class List { defaultAccess: this.defaultAccess.field, createAuxList: this.createAuxList, schemaNames: this._schemaNames, + type, }) ); this.fields = Object.values(this.fieldsByPath); diff --git a/packages/keystone/lib/providers/listCRUD.js b/packages/keystone/lib/providers/listCRUD.js index 5bc620942b0..7c2c2174b12 100644 --- a/packages/keystone/lib/providers/listCRUD.js +++ b/packages/keystone/lib/providers/listCRUD.js @@ -1,6 +1,8 @@ const { GraphQLJSON } = require('graphql-type-json'); const { flatten, objMerge, unique } = require('@keystonejs/utils'); +const interfaceType = '_ListSchemaFields'; + class ListCRUDProvider { constructor({ metaPrefix = 'ks' } = {}) { this.lists = []; @@ -13,6 +15,11 @@ class ListCRUDProvider { getTypes({ schemaName }) { return unique([ ...flatten(this.lists.map(list => list.getGqlTypes({ schemaName }))), + ...flatten( + this.lists.map(list => + flatten(list.fields.map(field => field.getGqlMetaTypes({ schemaName, interfaceType }))) + ) + ), `"""NOTE: Can be JSON, or a Boolean/Int/String Why not a union? GraphQL doesn't support a union including a scalar (https://github.com/facebook/graphql/issues/215)""" @@ -41,7 +48,7 @@ class ListCRUDProvider { user when performing 'auth' operations.""" auth: JSON }`, - `type _ListSchemaFields { + `interface ${interfaceType} { """The name of the field in its list.""" name: String @@ -64,7 +71,7 @@ class ListCRUDProvider { queries: [String] """Information about fields defined on this list. """ - fields(where: _ListSchemaFieldsInput): [_ListSchemaFields] + fields(where: _ListSchemaFieldsInput): [${interfaceType}] """Information about fields on other types which return this type, or provide aggregate information about this type""" @@ -138,11 +145,21 @@ class ListCRUDProvider { return this.lists .find(list => list.key === key) .getFieldsWithAccess({ schemaName, access: 'read' }) - .filter(field => !type || field.constructor.name === type) - .map(field => ({ - name: field.path, - type: field.constructor.name, - })); + .filter(field => !type || field.type.type === type) + .map(field => { + const result = field.gqlMetaQueryResolver({ interfaceType }); + // We do this check here, and not in the `${interfaceType}` + // __resolveType resolver because by the time that's executed we no + // longer know what field we were resolving against. + // Instead, we throw here with a more meaningful error. + if (!result.__typename) { + throw new Error( + `Cannot resolve field ${field.type.type}.__typename from ${field.type.type}::gqlMetaQueryResolver()` + ); + } + + return result; + }); }, // A function so we can lazily evaluate this potentially expensive @@ -165,6 +182,12 @@ class ListCRUDProvider { .filter(({ fields }) => fields.length), }; + const fieldSchemaResolver = { + // We have to implement this to avoid Apollo throwing an error about it + // missing (even though it works just fine without it, grrrr) + __resolveType: obj => obj.__typename, + }; + return { ...objMerge(this.lists.map(list => list.gqlAuxFieldResolvers({ schemaName }))), ...objMerge(this.lists.map(list => list.gqlFieldResolvers({ schemaName }))), @@ -173,6 +196,7 @@ class ListCRUDProvider { _ListMeta: listMetaResolver, _ListAccess: listAccessResolver, _ListSchema: listSchemaResolver, + [interfaceType]: fieldSchemaResolver, }; } getQueryResolvers({ schemaName }) { diff --git a/packages/keystone/tests/Keystone.test.js b/packages/keystone/tests/Keystone.test.js index e1793488cc3..9836b59cad5 100644 --- a/packages/keystone/tests/Keystone.test.js +++ b/packages/keystone/tests/Keystone.test.js @@ -39,12 +39,30 @@ class MockFieldImplementation { getGqlAuxMutations() { return ['mutateFoo: Boolean']; } + getGqlMetaTypes({ interfaceType }) { + return [ + ` + type _MockFieldMeta implements ${interfaceType} { + name: String + type: String + } + `, + ]; + } + gqlMetaQueryResolver() { + return { + __typename: '_MockFieldMeta', + name: this.path, + type: this.type.type, + }; + } extendAdminViews(views) { return views; } } const MockFieldType = { + type: 'MockField', implementation: MockFieldImplementation, views: {}, adapters: { mock: MockFieldAdapter }, @@ -572,6 +590,7 @@ describe('keystone.prepare()', () => { }; const mockMiddlewareFn = jest.fn(() => {}); const MockFieldWithMiddleware = { + type: 'MockFieldWithMiddleware', prepareMiddleware: jest.fn(() => mockMiddlewareFn), implementation: MockFieldImplementation, views: {},