diff --git a/examples/field-query-builder/README.md b/examples/field-query-builder/README.md new file mode 100644 index 00000000000..b80a4a85cd8 --- /dev/null +++ b/examples/field-query-builder/README.md @@ -0,0 +1,36 @@ +## Feature Example - Query Builder + +This project demonstrates how to configure a `queryBuilder` field in your Keystone system. It uses the `@react-awesome-query-builder` library to provide a user-friendly interface for building complex queries. This tool allows users, typically non-programmers, to construct and manipulate queries without needing to write SQL or other query languages directly. It's particularly useful for applications involving data analysis or any scenario where users need to filter or retrieve specific data sets from a larger database. + +## Instructions + +To run this project, clone the Keystone repository locally, run `pnpm install` at the root of the repository then navigate to this directory and run: + +```shell +pnpm dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). +You can use the Admin UI to create items in your database. + +You can also access a GraphQL Playground at [localhost:3000/api/graphql](http://localhost:3000/api/graphql), which allows you to directly run GraphQL queries and mutations. + +In a separate terminal, start the frontend dev server: + +``` +pnpm dev:site +``` + +This will start the frontend at [localhost:3001](http://localhost:3001). + +## Configuring + +The project contains one `queryBuilder` field which shows how to use the field configuration options to customise the document editor in the Admin UI. + +### `Report.query` + +This field shows an example of using statically defined (config.fields)[https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configfields]. + +## Try it out in CodeSandbox 🧪 + +You can play with this example online in a web browser using the free [codesandbox.io](https://codesandbox.io/) service. To launch this example, open the URL . You can also fork this sandbox to make your own changes. diff --git a/examples/field-query-builder/common.ts b/examples/field-query-builder/common.ts new file mode 100644 index 00000000000..48f9c9fddaf --- /dev/null +++ b/examples/field-query-builder/common.ts @@ -0,0 +1,217 @@ +const common = { + cardinalDirections: [ + { value: 'SE', title: 'South East' }, + { value: 'S', title: 'South' }, + { value: 'SW', title: 'South West' }, + { value: 'W', title: 'West' }, + { value: 'NW', title: 'North West' }, + { value: 'N', title: 'North' }, + { value: 'NE', title: 'North East' }, + { value: 'E', title: 'East' }, + ], + streetSuffixes: [ + { value: 'ALY', title: 'Alley' }, + { value: 'ANX', title: 'Annex' }, + { value: 'ARC', title: 'Arcade' }, + { value: 'AVE', title: 'Avenue' }, + { value: 'AVDA', title: 'Avenida' }, + { value: 'BLF', title: 'Bluff' }, + { value: 'BLVD', title: 'Boulevard' }, + { value: 'BR', title: 'Branch' }, + { value: 'BRG', title: 'Bridge' }, + { value: 'BRK', title: 'Brook' }, + { value: 'BTM', title: 'Bottom' }, + { value: 'BYU', title: 'Bayou' }, + { value: 'CALLE', title: 'Calle' }, + { value: 'CAMINO', title: 'Camino' }, + { value: 'CIR', title: 'Circle' }, + { value: 'CLF', title: 'Cliff' }, + { value: 'CLFS', title: 'Cliffs' }, + { value: 'CMN', title: 'Common' }, + { value: 'CMNO', title: 'Camino' }, + { value: 'CORS', title: 'Corner' }, + { value: 'COURT', title: 'Court' }, + { value: 'CP', title: 'Camp' }, + { value: 'CPE', title: 'Cape' }, + { value: 'CRK', title: 'Creek' }, + { value: 'CROSSING', title: 'Crossing' }, + { value: 'CSWY', title: 'Causeway' }, + { value: 'CT', title: 'Court' }, + { value: 'CTS', title: 'Courts' }, + { value: 'CV', title: 'Cove' }, + { value: 'CYN', title: 'Canyon' }, + { value: 'DL', title: 'Dale' }, + { value: 'DM', title: 'Dam' }, + { value: 'DR', title: 'Drive' }, + { value: 'DRS', title: 'Drives' }, + { value: 'ENT', title: 'Entrance' }, + { value: 'EST', title: 'Estate' }, + { value: 'ESTS', title: 'Estates' }, + { value: 'EXPY', title: 'Expressway' }, + { value: 'EXT', title: 'Extension' }, + { value: 'EXTS', title: 'Extensions' }, + { value: 'FLD', title: 'Field' }, + { value: 'FLDS', title: 'Fields' }, + { value: 'FLS', title: 'Falls' }, + { value: 'FLT', title: 'Flat' }, + { value: 'FM', title: 'Farm' }, + { value: 'FRD', title: 'Ford' }, + { value: 'FRG', title: 'Forge' }, + { value: 'FRK', title: 'Fork' }, + { value: 'FRKS', title: 'Forks' }, + { value: 'FRST', title: 'Forest' }, + { value: 'FRY', title: 'Ferry' }, + { value: 'FWY', title: 'Freeway' }, + { value: 'GDNS', title: 'Gardens' }, + { value: 'GLN', title: 'Glen' }, + { value: 'GRN', title: 'Green' }, + { value: 'GRNS', title: 'Greens' }, + { value: 'GRVS', title: 'Grove' }, + { value: 'GTWY', title: 'Gateway' }, + { value: 'HBR', title: 'Harbor' }, + { value: 'HVN', title: 'Haven' }, + { value: 'HWY', title: 'Highway' }, + { value: 'INLT', title: 'Inlet' }, + { value: 'IS', title: 'Island' }, + { value: 'ISLE', title: 'Isle' }, + { value: 'JCT', title: 'Junction' }, + { value: 'KNL', title: 'Knoll' }, + { value: 'KNLS', title: 'Knolls' }, + { value: 'KY', title: 'Key' }, + { value: 'KYS', title: 'Keys' }, + { value: 'LCK', title: 'Lock' }, + { value: 'LCKS', title: 'Locks' }, + { value: 'LDG', title: 'Lodge' }, + { value: 'LF', title: 'Loaf' }, + { value: 'LGT', title: 'Light' }, + { value: 'LGTS', title: 'Lights' }, + { value: 'LNDG', title: 'Landing' }, + { value: 'LN', title: 'Lane' }, + { value: 'LKS', title: 'Lakes' }, + { value: 'MNR', title: 'Manor' }, + { value: 'MDW', title: 'Meadow' }, + { value: 'MDWS', title: 'Meadows' }, + { value: 'MEWS', title: 'Mews' }, + { value: 'MLS', title: 'Mills' }, + { value: 'MSN', title: 'Mission' }, + { value: 'MT', title: 'Mount' }, + { value: 'MTN', title: 'Mountain' }, + { value: 'MTNS', title: 'Mountains' }, + { value: 'MTWY', title: 'Motorway' }, + { value: 'NCK', title: 'Neck' }, + { value: 'ORCH', title: 'Orchard' }, + { value: 'PARK', title: 'Park' }, + { value: 'PARKWAY', title: 'Parkway' }, + { value: 'PASAJE', title: 'Pasaje' }, + { value: 'PASEO', title: 'Paseo' }, + { value: 'PATH', title: 'Path' }, + { value: 'PH', title: 'Path' }, + { value: 'PIKE', title: 'Pike' }, + { value: 'PL', title: 'Place' }, + { value: 'PLN', title: 'Plain' }, + { value: 'PLNS', title: 'Plains' }, + { value: 'PLZ', title: 'Plaza' }, + { value: 'PNES', title: 'Pines' }, + { value: 'PRT', title: 'Port' }, + { value: 'PTS', title: 'Points' }, + { value: 'RADL', title: 'Radial' }, + { value: 'RAMP', title: 'Ramp' }, + { value: 'RD', title: 'Road' }, + { value: 'RDG', title: 'Ridge' }, + { value: 'RDGS', title: 'Ridges' }, + { value: 'RIV', title: 'River' }, + { value: 'RNCH', title: 'Ranch' }, + { value: 'RPD', title: 'Rapid' }, + { value: 'RPDS', title: 'Rapids' }, + { value: 'RST', title: 'Rest' }, + { value: 'ROUTE', title: 'Route' }, + { value: 'ROW', title: 'Row' }, + { value: 'RUE', title: 'Rue' }, + { value: 'RUN', title: 'Run' }, + { value: 'SHL', title: 'Shoal' }, + { value: 'SHLS', title: 'Shoals' }, + { value: 'SHR', title: 'Shore' }, + { value: 'SHRS', title: 'Shores' }, + { value: 'SKWY', title: 'Skyway' }, + { value: 'SPG', title: 'Spring' }, + { value: 'SPGS', title: 'Springs' }, + { value: 'SPUR', title: 'Spur' }, + { value: 'SQ', title: 'Square' }, + { value: 'SQS', title: 'Squares' }, + { value: 'STA', title: 'Station' }, + { value: 'STRA', title: 'Stravenue' }, + { value: 'STRM', title: 'Stream' }, + { value: 'ST', title: 'Street' }, + { value: 'STS', title: 'Streets' }, + { value: 'TER', title: 'Terrace' }, + { value: 'TPKE', title: 'Turnpike' }, + { value: 'TRCE', title: 'Trace' }, + { value: 'TRAK', title: 'Track' }, + { value: 'TRL', title: 'Trail' }, + { value: 'TRWY', title: 'Throughway' }, + { value: 'TUNL', title: 'Tunnel' }, + { value: 'US', title: 'United States' }, + { value: 'UN', title: 'Union' }, + { value: 'VALLEY', title: 'Valley' }, + { value: 'VIADUCT', title: 'Viaduct' }, + { value: 'VL', title: 'Ville' }, + { value: 'VW', title: 'View' }, + { value: 'VWS', title: 'Views' }, + { value: 'VLG', title: 'Village' }, + ], + states: [ + { value: 'AK', title: 'Alaska' }, + { value: 'AL', title: 'Alabama' }, + { value: 'AR', title: 'Arkansas' }, + { value: 'AZ', title: 'Arizona' }, + { value: 'CA', title: 'California' }, + { value: 'CO', title: 'Colorado' }, + { value: 'CT', title: 'Connecticut' }, + { value: 'DC', title: 'District of Columbia' }, + { value: 'DE', title: 'Delaware' }, + { value: 'FL', title: 'Florida' }, + { value: 'GA', title: 'Georgia' }, + { value: 'HI', title: 'Hawaii' }, + { value: 'IA', title: 'Iowa' }, + { value: 'ID', title: 'Idaho' }, + { value: 'IL', title: 'Illinois' }, + { value: 'IN', title: 'Indiana' }, + { value: 'KS', title: 'Kansas' }, + { value: 'KY', title: 'Kentucky' }, + { value: 'LA', title: 'Louisiana' }, + { value: 'MA', title: 'Massachusetts' }, + { value: 'MD', title: 'Maryland' }, + { value: 'ME', title: 'Maine' }, + { value: 'MI', title: 'Michigan' }, + { value: 'MN', title: 'Minnesota' }, + { value: 'MO', title: 'Missouri' }, + { value: 'MS', title: 'Mississippi' }, + { value: 'MT', title: 'Montana' }, + { value: 'NC', title: 'North Carolina' }, + { value: 'ND', title: 'North Dakota' }, + { value: 'NE', title: 'Nebraska' }, + { value: 'NH', title: 'New Hampshire' }, + { value: 'NJ', title: 'New Jersey' }, + { value: 'NM', title: 'New Mexico' }, + { value: 'NV', title: 'Nevada' }, + { value: 'NY', title: 'New York' }, + { value: 'OH', title: 'Ohio' }, + { value: 'OK', title: 'Oklahoma' }, + { value: 'OR', title: 'Oregon' }, + { value: 'PA', title: 'Pennsylvania' }, + { value: 'RI', title: 'Rhode Island' }, + { value: 'SC', title: 'South Carolina' }, + { value: 'SD', title: 'South Dakota' }, + { value: 'TN', title: 'Tennessee' }, + { value: 'TX', title: 'Texas' }, + { value: 'UT', title: 'Utah' }, + { value: 'VA', title: 'Virginia' }, + { value: 'VT', title: 'Vermont' }, + { value: 'WA', title: 'Washington' }, + { value: 'WI', title: 'Wisconsin' }, + { value: 'WV', title: 'West Virginia' }, + { value: 'WY', title: 'Wyoming' }, + ], +}; + +export default common diff --git a/examples/field-query-builder/keystone.ts b/examples/field-query-builder/keystone.ts new file mode 100644 index 00000000000..fe1bf98ab98 --- /dev/null +++ b/examples/field-query-builder/keystone.ts @@ -0,0 +1,14 @@ +import { config } from '@keystone-6/core' +import { fixPrismaPath } from '../example-utils' +import { lists } from './schema' + +export default config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + + // WARNING: this is only needed for our monorepo examples, dont do this + ...fixPrismaPath, + }, + lists, +}) diff --git a/examples/field-query-builder/next-env.d.ts b/examples/field-query-builder/next-env.d.ts new file mode 100644 index 00000000000..7b7aa2c7727 --- /dev/null +++ b/examples/field-query-builder/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/field-query-builder/next.config.js b/examples/field-query-builder/next.config.js new file mode 100644 index 00000000000..00184d61daa --- /dev/null +++ b/examples/field-query-builder/next.config.js @@ -0,0 +1,5 @@ +// you don't need this if you're building something outside of the Keystone repo + +const withPreconstruct = require('@preconstruct/next') + +module.exports = withPreconstruct() diff --git a/examples/field-query-builder/package.json b/examples/field-query-builder/package.json new file mode 100644 index 00000000000..a22f28363b7 --- /dev/null +++ b/examples/field-query-builder/package.json @@ -0,0 +1,27 @@ +{ + "name": "@keystone-6/example-document-field", + "version": "0.1.3", + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "dev:site": "next dev -p 3001", + "start": "keystone start", + "build": "keystone build", + "postinstall": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/core": "^6.0.0", + "@keystone-6/document-renderer": "^1.1.0", + "@keystone-6/fields-query-builder": "workspace:^", + "@preconstruct/next": "^4.0.0", + "@prisma/client": "^5.0.0", + "next": "^13.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "prisma": "^5.0.0", + "typescript": "~5.0.0" + } +} diff --git a/examples/field-query-builder/sandbox.config.json b/examples/field-query-builder/sandbox.config.json new file mode 100644 index 00000000000..c5d3215212f --- /dev/null +++ b/examples/field-query-builder/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "template": "node", + "container": { + "startScript": "keystone dev", + "node": "20" + } +} diff --git a/examples/field-query-builder/schema.graphql b/examples/field-query-builder/schema.graphql new file mode 100644 index 00000000000..2793d2d5593 --- /dev/null +++ b/examples/field-query-builder/schema.graphql @@ -0,0 +1,229 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type Report { + id: ID! + title: String + query: String +} + +input ReportWhereUniqueInput { + id: ID + title: String +} + +input ReportWhereInput { + AND: [ReportWhereInput!] + OR: [ReportWhereInput!] + NOT: [ReportWhereInput!] + id: IDFilter + title: StringFilter + query: StringNullableFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input StringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input NestedStringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input StringNullableFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: StringNullableFilter +} + +input ReportOrderByInput { + id: OrderDirection + title: OrderDirection + query: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input ReportUpdateInput { + title: String + query: String +} + +input ReportUpdateArgs { + where: ReportWhereUniqueInput! + data: ReportUpdateInput! +} + +input ReportCreateInput { + title: String + query: String +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type Mutation { + createReport(data: ReportCreateInput!): Report + createReports(data: [ReportCreateInput!]!): [Report] + updateReport(where: ReportWhereUniqueInput!, data: ReportUpdateInput!): Report + updateReports(data: [ReportUpdateArgs!]!): [Report] + deleteReport(where: ReportWhereUniqueInput!): Report + deleteReports(where: [ReportWhereUniqueInput!]!): [Report] +} + +type Query { + reports(where: ReportWhereInput! = {}, orderBy: [ReportOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: ReportWhereUniqueInput): [Report!] + report(where: ReportWhereUniqueInput!): Report + reportsCount(where: ReportWhereInput! = {}): Int + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! + isSingleton: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/field-query-builder/schema.prisma b/examples/field-query-builder/schema.prisma new file mode 100644 index 00000000000..0f49bff92d1 --- /dev/null +++ b/examples/field-query-builder/schema.prisma @@ -0,0 +1,19 @@ +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource sqlite { + url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.myprisma/client" +} + +model Report { + id String @id @default(cuid()) + title String @unique @default("") + query String? +} diff --git a/examples/field-query-builder/schema.ts b/examples/field-query-builder/schema.ts new file mode 100644 index 00000000000..1c534398f84 --- /dev/null +++ b/examples/field-query-builder/schema.ts @@ -0,0 +1,86 @@ +import { list } from '@keystone-6/core' +import { text } from '@keystone-6/core/fields' +import { queryBuilder } from '@keystone-6/fields-query-builder' +import { allowAll } from '@keystone-6/core/access' +import common from './common' + +import type { Lists } from '.keystone/types' + +export const lists = { + Report: list({ + access: allowAll, + fields: { + title: text({ + validation: { isRequired: true }, + isIndexed: "unique", + }), + query: queryBuilder({ + fields: { + primaryNumber: { + label: 'Primary Number', + type: 'text', + }, + streetPredirection: { + label: 'Pre-Direction', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: common.cardinalDirections, + }, + }, + streetName: { + label: 'Street Name', + type: 'text', + }, + streetSuffix: { + label: 'Street Suffix', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: common.streetSuffixes, + }, + }, + streetPostdirection: { + label: 'Post-Direction', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: common.cardinalDirections, + }, + }, + secondaryDesignator: { + label: 'Secondary Designator', + type: 'text', + }, + secondaryNumber: { + label: 'Secondary Number', + type: 'text', + }, + city: { + label: 'City', + type: 'text', + }, + state: { + label: 'State', + type: 'select', + valueSources: ['value'], + fieldSettings: { + listValues: common.states, + }, + }, + zipCode: { + label: 'Zip Code', + type: 'text', + }, + plus4Code: { + label: '+4 Code', + type: 'text', + }, + }, + ui: { + style: "antd" + } + }) + }, + }), +} satisfies Lists diff --git a/examples/field-query-builder/tsconfig.json b/examples/field-query-builder/tsconfig.json new file mode 100644 index 00000000000..1ed5fb820cb --- /dev/null +++ b/examples/field-query-builder/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": false, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index 7a1798bf5e9..b3f8f53cf14 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -30,3 +30,4 @@ export { calendarDay } from './types/calendarDay' export type { CalendarDayFieldConfig } from './types/calendarDay' export { multiselect } from './types/multiselect' export type { MultiselectFieldConfig } from './types/multiselect' +export { filters } from './filters' diff --git a/packages/fields-query-builder/LICENSE b/packages/fields-query-builder/LICENSE new file mode 100644 index 00000000000..9cf106272ac --- /dev/null +++ b/packages/fields-query-builder/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/fields-query-builder/package.json b/packages/fields-query-builder/package.json new file mode 100644 index 00000000000..e989440160c --- /dev/null +++ b/packages/fields-query-builder/package.json @@ -0,0 +1,46 @@ +{ + "name": "@keystone-6/fields-query-builder", + "version": "0.1.0", + "license": "MIT", + "main": "dist/keystone-6-fields-query-builder.cjs.js", + "module": "dist/keystone-6-fields-query-builder.esm.js", + "exports": { + ".": { + "module": "./dist/keystone-6-fields-query-builder.esm.js", + "default": "./dist/keystone-6-fields-query-builder.cjs.js" + }, + "./views": { + "module": "./views/dist/keystone-6-fields-query-builder-views.esm.js", + "default": "./views/dist/keystone-6-fields-query-builder-views.cjs.js" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@apollo/client": "^3.9.2", + "@babel/runtime": "^7.16.3", + "@keystone-ui/fields": "workspace:^", + "@react-awesome-query-builder/antd": "^6.0.0", + "@react-awesome-query-builder/core": "^6.0.0", + "@react-awesome-query-builder/ui": "^6.0.0", + "@types/react": "^18.0.9", + "antd": "^5.0.0", + "lodash.merge": "^4.0.0", + "react": "^18.2.0" + }, + "peerDependencies": { + "@keystone-6/core": "^6.0.0" + }, + "devDependencies": { + "@keystone-6/core": "workspace:^", + "@types/express": "^4.17.14", + "graphql": "^16.8.1", + "graphql-upload": "^15.0.2" + }, + "preconstruct": { + "entrypoints": [ + "index.ts", + "views/index.tsx" + ] + }, + "repository": "https://github.com/keystonejs/keystone/tree/main/packages/fields-query-builder" +} diff --git a/packages/fields-query-builder/src/index.ts b/packages/fields-query-builder/src/index.ts new file mode 100644 index 00000000000..058190e223f --- /dev/null +++ b/packages/fields-query-builder/src/index.ts @@ -0,0 +1,94 @@ +import { + type BaseListTypeInfo, + fieldType, + type FieldTypeFunc, + type CommonFieldConfig, + orderDirectionEnum, + JSONValue, +} from '@keystone-6/core/types'; +import { graphql } from '@keystone-6/core'; +import { filters } from '@keystone-6/core/fields'; +import { getNamedType } from "graphql"; + +export type FilterDepenancy = { + list?: string; // The specific list the dependency is part of. + field?: string; // The specific field within the list that forms the dependency. +}; +type FilterConfig = { + isIndexed?: boolean | "unique"; + ui?: { + style?: "default" | "antd"; + }; + fields?: JSONValue | null; + dependency?: FilterDepenancy | null; +}; +export type FilterFieldConfig = + CommonFieldConfig & FilterConfig; +export type FilterViewConfig = { + style: "default" | "antd"; + fields: JSONValue | null; + dependency: FilterDepenancy | null; +}; + +export function queryBuilder({ + isIndexed, + ...config +}: FilterFieldConfig = {}): FieldTypeFunc { + const mode = isIndexed === "unique" ? "required" : "optional"; + return (meta) => + fieldType({ + kind: "scalar", + mode: "optional", + scalar: "String", + index: isIndexed === true ? "index" : isIndexed || undefined, + })({ + ...config, + input: { + uniqueWhere: + isIndexed === "unique" + ? { arg: graphql.arg({ type: graphql.String }) } + : undefined, + where: { + arg: graphql.arg({ + type: filters[meta.provider].String[mode], + }), + resolve: mode === "required" ? undefined : filters.resolveString, + }, + create: { + arg: graphql.arg({ type: graphql.String }), + resolve(value) { + return value; + }, + }, + update: { arg: graphql.arg({ type: graphql.String }) }, + orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, + }, + output: graphql.field({ + type: graphql.String, + resolve({ value }) { + return value; + }, + }), + views: "@keystone-6/fields-query-builder/views", + getAdminMeta() { + const fields = config.fields || {}; + if (config.dependency?.field) { + // Handle field-specific dependencies. + const field = config.dependency.field.split(".")[0]; + if (!config.dependency?.list) { + // Determine the list of the dependency if not explicitly set. + config.dependency.list = getNamedType( + meta.lists[meta.listKey].types.output.graphQLType.getFields()[ + field + ].type, + ).name; + } + } + return { + style: config.ui?.style || null, + fields: fields || null, + dependency: config.dependency || null, + }; + }, + }); +} diff --git a/packages/fields-query-builder/src/views/components/config.tsx b/packages/fields-query-builder/src/views/components/config.tsx new file mode 100644 index 00000000000..c675606db97 --- /dev/null +++ b/packages/fields-query-builder/src/views/components/config.tsx @@ -0,0 +1,26 @@ +import merge from "lodash.merge"; +import ListInputConfig from "./widgets/ListInput"; +import { BasicFuncs } from "@react-awesome-query-builder/ui"; + +const Config = merge(ListInputConfig, { + funcs: { + ...BasicFuncs, + }, + widgets: { + date: { + dateFormat: "YYYY-MM-DD", + valueFormat: "YYYY-MM-DD", + }, + datetime: { + timeFormat: "HH:mm:ss", + dateFormat: "YYYY-MM-DD", + valueFormat: "YYYY-MM-DD HH:mm:ss", + }, + time: { + timeFormat: "HH:mm:ss", + valueFormat: "HH:mm:ss", + }, + }, +}); + +export default Config; diff --git a/packages/fields-query-builder/src/views/components/dependent.tsx b/packages/fields-query-builder/src/views/components/dependent.tsx new file mode 100644 index 00000000000..3b05e89a093 --- /dev/null +++ b/packages/fields-query-builder/src/views/components/dependent.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react/prop-types */ +import { useEffect } from "react"; +import { getGqlNames } from "@keystone-6/core/types"; +import { useKeystone } from "@keystone-6/core/admin-ui/context"; +import { gql, useQuery } from "@apollo/client"; +import { ComponentProps } from ".."; +import React from "react"; + +export default function FieldDependency( + props: ComponentProps & { + field: { config: { dependency: { list: string; field: string } } }; + }, +) { + // Utilizes the Keystone context for global state and GraphQL endpoint access. + const keystone = useKeystone(); + // Determines the GraphQL list key and names based on field metadata. + const gqlNames = getGqlNames({ + listKey: props.field.config.dependency.list, + pluralGraphQLName: keystone.adminMeta.lists[ + props.field.config.dependency.list + ].plural.replace(" ", ""), + }); + + // Identifies if there's a dependency value to fetch related fields dynamically. + const dependent = + props.itemValue[props.field.config.dependency.field.split(".")[0]]; + + const dependentID = + dependent?.value?.inner?.value ?? + dependent?.value?.value?.id ?? + dependent?.value ?? + undefined; + + const results = useQuery( + gql`query($id: ID!) { + item: ${gqlNames.itemQueryName}(where: {id: $id}) { + ${createNestedString( + (props.field.config.dependency?.field || "").split(".").slice(1), + )} + } + }`, + { + variables: { id: dependentID }, + fetchPolicy: "no-cache", + errorPolicy: "all", + skip: dependentID === null, + }, + ); + + // Fetches and updates the field's options based on the dependency's value. + useEffect(() => { + if (props.field.config.dependency?.field && results.data) { + const value = selectNestedKey( + props.field.config.dependency.field.split(".").slice(1), + results.data.item, + ); + if (value) props.setFields(JSON.parse(value)); + } + }, [results.data]); + + return <>; +} + +function createNestedString(fields: string[]): string { + let nestedString = ""; + for (let i = fields.length - 1; i >= 0; i--) { + if (i === fields.length - 1) { + // First iteration (actually the last element of the array) + nestedString = fields[i]; + } else { + // Wrap the current field around the nestedString + nestedString = `${fields[i]} { ${nestedString} }`; + } + } + return nestedString; +} +function selectNestedKey(path: string[], obj: any): any { + let result = obj; + for (const key of path) { + if (result[key] === undefined) { + return undefined; + } + result = result[key]; + } + return result; +} diff --git a/packages/fields-query-builder/src/views/components/styles/antd.tsx b/packages/fields-query-builder/src/views/components/styles/antd.tsx new file mode 100644 index 00000000000..9945c5f2c0e --- /dev/null +++ b/packages/fields-query-builder/src/views/components/styles/antd.tsx @@ -0,0 +1,91 @@ +import React, { useState, useCallback, useEffect } from "react"; +import merge from "lodash.merge"; +import InitConfig from "../config"; + +import type { + Config, + ImmutableTree, + BuilderProps, +} from "@react-awesome-query-builder/antd"; +import { + Query, + Builder, + Utils as QbUtils, + AntdConfig as BasicConfig, +} from "@react-awesome-query-builder/antd"; +import "@react-awesome-query-builder/antd/css/styles.css"; +import { ComponentProps } from "../.."; + +// Define the View component. +export default function View(props: ComponentProps) { + // State for the query builder configuration, initialized with the initial configuration and fields. + const [config, setConfig] = useState({ + ...merge(BasicConfig, InitConfig), + fields: props.fields, + }); + + // Effect hook to update fields in the configuration whenever props.fields change. + useEffect(() => { + if (props.fields && Object.keys(props.fields).length > 0) { + setConfig((prev) => ({ ...prev, fields: props.fields })); + } + }, [props.fields]); + + // State for the query builder tree, initialized with a default group node. + const [tree, setTree] = useState( + QbUtils.loadTree({ + id: QbUtils.uuid(), // Generate a unique ID for the root node. + type: "group", // Set the node type to "group". + }), + ); + + // Effect hook to update the query builder tree whenever the config changes. + useEffect(() => { + if ( + Object.keys(config.fields).length > 0 && + props.value && + Object.keys(props.value).length > 0 + ) { + setTree((prev) => { + return props.value + ? QbUtils.loadFromJsonLogic(props.value, config) || prev + : prev; + }); + } + }, [config]); + + // Callback to handle changes in the query builder's state. + const onChange = useCallback( + (value: ImmutableTree, config: Config) => { + setTree(value); + if (props.onChange) { + const logic = QbUtils.jsonLogicFormat(value, config)["logic"]; + if (logic) props.onChange(JSON.stringify(logic)); + } + }, + [props.onChange], + ); + + // Callback to render the query builder UI. + const renderBuilder = useCallback((props: BuilderProps) => { + return ( +
+
+ {Object.keys(props.config.fields).length > 0 ? ( + + ) : null} +
+
+ ); + }, []); + + // Render the Query component with the current configuration and tree, or null if no fields are defined. + return ( + + ); +} diff --git a/packages/fields-query-builder/src/views/components/styles/default.tsx b/packages/fields-query-builder/src/views/components/styles/default.tsx new file mode 100644 index 00000000000..9cbce8bcf33 --- /dev/null +++ b/packages/fields-query-builder/src/views/components/styles/default.tsx @@ -0,0 +1,91 @@ +import React, { useState, useCallback, useEffect } from "react"; +import merge from "lodash.merge"; +import InitConfig from "../config"; + +import type { + Config, + ImmutableTree, + BuilderProps, +} from "@react-awesome-query-builder/ui"; +import { + Query, + Builder, + Utils as QbUtils, + BasicConfig, +} from "@react-awesome-query-builder/ui"; +import "@react-awesome-query-builder/ui/css/styles.css"; +import { ComponentProps } from "../.."; + +// Define the View component. +export default function View(props: ComponentProps) { + // State for the query builder configuration, initialized with the initial configuration and fields. + const [config, setConfig] = useState({ + ...merge(BasicConfig, InitConfig), + fields: props.fields, + }); + + // Effect hook to update fields in the configuration whenever props.fields change. + useEffect(() => { + if (props.fields && Object.keys(props.fields).length > 0) { + setConfig((prev) => ({ ...prev, fields: props.fields })); + } + }, [props.fields]); + + // State for the query builder tree, initialized with a default group node. + const [tree, setTree] = useState( + QbUtils.loadTree({ + id: QbUtils.uuid(), // Generate a unique ID for the root node. + type: "group", // Set the node type to "group". + }), + ); + + // Effect hook to update the query builder tree whenever the config changes. + useEffect(() => { + if ( + Object.keys(config.fields).length > 0 && + props.value && + Object.keys(props.value).length > 0 + ) { + setTree((prev) => { + return props.value + ? QbUtils.loadFromJsonLogic(props.value, config) || prev + : prev; + }); + } + }, [config]); + + // Callback to handle changes in the query builder's state. + const onChange = useCallback( + (value: ImmutableTree, config: Config) => { + setTree(value); + if (props.onChange) { + const logic = QbUtils.jsonLogicFormat(value, config)["logic"]; + if (logic) props.onChange(JSON.stringify(logic)); + } + }, + [props.onChange], + ); + + // Callback to render the query builder UI. + const renderBuilder = useCallback((props: BuilderProps) => { + return ( +
+
+ {Object.keys(props.config.fields).length > 0 ? ( + + ) : null} +
+
+ ); + }, []); + + // Render the Query component with the current configuration and tree, or null if no fields are defined. + return ( + + ); +} diff --git a/packages/fields-query-builder/src/views/components/widgets/ListInput/config.tsx b/packages/fields-query-builder/src/views/components/widgets/ListInput/config.tsx new file mode 100644 index 00000000000..3995b2cfac0 --- /dev/null +++ b/packages/fields-query-builder/src/views/components/widgets/ListInput/config.tsx @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React from "react"; +import type { WidgetProps } from "@react-awesome-query-builder/ui"; +import { ListInputWidget } from "."; + +export const Config = { + operators: { + in: { + elasticSearchQueryType: "term", + jsonLogic: "in", + label: "Any In", + labelForFormat: "IN", + formatOp: ( + field: unknown, + _op: string, + value: string, + _valueSrc: unknown, + _valueType: unknown, + _opDef: unknown, + ) => { + const formattedValues = value + .split(",") + .map((v) => `'${v}'`) + .join(", "); + return `${field} NOT IN (${formattedValues})`; + }, + reversedOp: "not_in", + valueSources: ["value", "values"], + valueTypes: ["text"], + }, + not_in: { + elasticSearchQueryType: "term", + jsonLogic: "in", + label: "Not In", + labelForFormat: "NOT IN", + formatOp: ( + field: unknown, + _op: string, + value: string, + _valueSrc: unknown, + _valueType: unknown, + _opDef: unknown, + ) => { + const formattedValues = value + .split(",") + .map((v) => `'${v}'`) + .join(", "); + return `${field} NOT IN (${formattedValues})`; + }, + reversedOp: "in", + valueSources: ["value", "values"], + valueTypes: ["text"], + }, + }, + types: { + text: { + widgets: { + listInput: { + operators: ["in", "not_in"], + }, + }, + }, + }, + widgets: { + listInput: { + valueSrc: "value", + factory: (props: WidgetProps) => , + }, + }, +}; diff --git a/packages/fields-query-builder/src/views/components/widgets/ListInput/index.tsx b/packages/fields-query-builder/src/views/components/widgets/ListInput/index.tsx new file mode 100644 index 00000000000..bd592f3a236 --- /dev/null +++ b/packages/fields-query-builder/src/views/components/widgets/ListInput/index.tsx @@ -0,0 +1,69 @@ +import React, { useState, ChangeEvent, ClipboardEvent } from "react"; +import { WidgetProps } from "@react-awesome-query-builder/ui"; +import { Config } from "./config"; + +const ListInputWidget: React.FC = (props) => { + const [list, setList] = useState(props.value || []); + + const handleAdd = (): void => { + setList([...list, ""]); + }; + + const handleChange = (index: number, newValue: string): void => { + const newList = [...list]; + newList[index] = newValue; + setList(newList); + props.setValue(newList); + }; + + const handleRemove = (index: number): void => { + const newList = list.filter((_, i) => i !== index); + setList(newList); + props.setValue(newList); + }; + + const handlePaste = (e: ClipboardEvent): void => { + e.preventDefault(); + const pastedData = e.clipboardData.getData("text"); + const pastedValues = pastedData + .split(/[,|\n|\r|\n\r|\r\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + + if (pastedValues.length > 0) { + // Check and remove the first item if it's blank before adding pasted values + let newList = [...list]; + if (newList.length > 0 && newList[0] === "") { + newList.shift(); // Remove the first item if it's blank + } + // Combine newList with pastedValues, filter for uniqueness + newList = [...new Set([...newList, ...pastedValues])]; + setList(newList); + props.setValue(newList); + } + }; + + return ( +
+ {list.map((item, index) => ( +
+ ) => + handleChange(index, e.target.value) + } + onPaste={handlePaste} + placeholder="Enter value" // Updated placeholder text + aria-label="List item" // Added for accessibility + /> + +
+ ))} + +
+ ); +}; + +export default Config; +export { ListInputWidget }; diff --git a/packages/fields-query-builder/src/views/index.tsx b/packages/fields-query-builder/src/views/index.tsx new file mode 100644 index 00000000000..5302bf6f865 --- /dev/null +++ b/packages/fields-query-builder/src/views/index.tsx @@ -0,0 +1,100 @@ +/* eslint-disable react/prop-types */ +import React, { useState } from "react"; +import { + FieldContainer, + FieldDescription, + FieldLabel, +} from "@keystone-ui/fields"; +import { CellContainer, CellLink } from "@keystone-6/core/admin-ui/components"; +import { + CardValueComponent, + CellComponent, + FieldController, + FieldControllerConfig, + FieldProps, +} from "@keystone-6/core/types"; +import { FilterDepenancy, FilterViewConfig } from ".."; +import DefaultView from "./components/styles/default"; +import AntdView from "./components/styles/antd"; +import { Fields } from "@react-awesome-query-builder/ui"; +import FieldDependency from "./components/dependent"; +export type ViewProps = FieldProps; +export type ComponentProps = ViewProps & { + fields: Fields; + setFields: (value: Fields) => void; +}; +export function Field(props: ViewProps) { + // State to manage the fields data, fetched based on dependency. + const [fields, setFields] = useState(props.field.config.fields || {}); + + const wrappedProps: ComponentProps = { + ...props, + value: JSON.parse(props.value || "null"), + fields: fields, + setFields, + }; + let Interface; + switch (props.field.config.style) { + case "antd": + Interface = AntdView; + break; + default: + Interface = DefaultView; + } + return ( + + {props.field.label} + + {props.field.description} + + {props.field.config.dependency?.field && ( + + )} + + + ); +} + +export const Cell: CellComponent = ({ item, field, linkTo }) => { + const value = item[field.path] + ""; + return linkTo ? ( + {value} + ) : ( + {value} + ); +}; +Cell.supportsLinkTo = true; + +export const CardValue: CardValueComponent = ({ item, field }) => { + return ( + + {field.label} + {item[field.path]} + + ); +}; + +type FilterController = FieldController & { + config: FilterViewConfig; +}; +export const controller = ( + config: FieldControllerConfig, +): FilterController => { + return { + config: config.fieldMeta, + path: config.path, + label: config.label, + description: config.description, + graphqlSelection: config.path, + defaultValue: null, + deserialize: (data) => { + const value = data[config.path]; + return typeof value === "string" ? value : null; + }, + serialize: (value) => ({ [config.path]: value }), + }; +}; diff --git a/packages/fields-query-builder/views/package.json b/packages/fields-query-builder/views/package.json new file mode 100644 index 00000000000..8ca2ead8a81 --- /dev/null +++ b/packages/fields-query-builder/views/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/keystone-6-fields-query-builder-views.cjs.js", + "module": "dist/keystone-6-fields-query-builder-views.esm.js" +}