├── .gitignore ├── filters-display ├── .gitignore ├── src │ ├── shims.d.ts │ ├── index.ts │ ├── utils.ts │ ├── display.vue │ └── components │ │ └── nodes.vue ├── package.json └── tsconfig.json ├── preview.png ├── src ├── shims.d.ts ├── parseFilter.ts ├── index.ts ├── intl.ts ├── utils.ts ├── components │ ├── input-component.vue │ ├── input-group.vue │ ├── select-dropdown-m2o.vue │ └── nodes.vue └── interface.vue ├── .vscode └── settings.json ├── tsconfig.json ├── package.json ├── LICENSE ├── README.md └── RELATIONSHIP_FILTERS_GUIDE.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /filters-display/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/u12206050/directus-extension-filters-interface/HEAD/preview.png -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { DefineComponent } from 'vue'; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /filters-display/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { DefineComponent } from 'vue'; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "testing.automaticallyOpenPeekView": "never", 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "prettier.arrowParens": "avoid", 5 | "prettier.printWidth": 120, 6 | "prettier.singleQuote": true, 7 | "prettier.useTabs": true, 8 | "prettier.jsxSingleQuote": true, 9 | "editor.formatOnSave": true 10 | } -------------------------------------------------------------------------------- /filters-display/src/index.ts: -------------------------------------------------------------------------------- 1 | import { defineDisplay } from '@directus/extensions-sdk'; 2 | import DisplayComponent from './display.vue'; 3 | 4 | export default defineDisplay({ 5 | id: 'filters-display', 6 | name: 'Filters&Rules', 7 | icon: 'search', 8 | description: 'A display for filter and rules', 9 | component: DisplayComponent, 10 | options: null, 11 | types: ['json'], 12 | }); 13 | -------------------------------------------------------------------------------- /filters-display/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directus-extension-filters-display", 3 | "version": "1.0.0", 4 | "keywords": [ 5 | "directus", 6 | "directus-extension", 7 | "directus-custom-display" 8 | ], 9 | "directus:extension": { 10 | "type": "display", 11 | "path": "dist/index.js", 12 | "source": "src/index.ts", 13 | "host": "^9.24.0" 14 | }, 15 | "scripts": { 16 | "build": "directus-extension build", 17 | "dev": "directus-extension build -w --no-minify" 18 | }, 19 | "devDependencies": { 20 | "@directus/extensions-sdk": "^9.26.0", 21 | "typescript": "^4.9.5", 22 | "vue": "^3.2.47" 23 | }, 24 | "dependencies": { 25 | "lodash-es": "^4.17.21" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["ES2019", "DOM"], 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedParameters": true, 15 | "alwaysStrict": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "resolveJsonModule": false, 21 | "skipLibCheck": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "allowSyntheticDefaultImports": true, 24 | "isolatedModules": true, 25 | "rootDir": "./src", 26 | "sourceMap": true 27 | }, 28 | "include": ["./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /filters-display/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "lib": ["ES2019", "DOM"], 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedParameters": true, 15 | "alwaysStrict": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "resolveJsonModule": false, 21 | "skipLibCheck": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "allowSyntheticDefaultImports": true, 24 | "isolatedModules": true, 25 | "rootDir": "./src", 26 | "sourceMap": true 27 | }, 28 | "include": ["./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directus-extension-filters-interface", 3 | "version": "2.2.0", 4 | "author": { 5 | "name": "Gerard Lamusse" 6 | }, 7 | "type": "module", 8 | "keywords": [ 9 | "directus", 10 | "directus-extension", 11 | "directus-custom-interface" 12 | ], 13 | "directus:extension": { 14 | "type": "interface", 15 | "path": "dist/index.js", 16 | "source": "src/index.ts", 17 | "host": "^11.0.0" 18 | }, 19 | "scripts": { 20 | "build": "directus-extension build", 21 | "dev": "directus-extension build -w --no-minify" 22 | }, 23 | "devDependencies": { 24 | "@directus/extensions-sdk": "^12.1.4", 25 | "@directus/utils": "^12.0.0", 26 | "@types/lodash-es": "^4.17.12", 27 | "rule-filter-validator": "^1.6.0", 28 | "sass": "^1.92.1", 29 | "typescript": "^5.9.2", 30 | "vue": "^3.5.13", 31 | "vuedraggable": "^4.1.0" 32 | }, 33 | "dependencies": { 34 | "lodash-es": "^4.17.21" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gerard Lamusse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /filters-display/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from '@directus/shared/types'; 2 | import { get } from 'lodash-es'; 3 | 4 | export function getNodeName(node: Filter): string { 5 | return Object.keys(node)[0] as string; 6 | } 7 | 8 | export function getField(node: Record): string { 9 | const name = getNodeName(node); 10 | if (name.startsWith('_')) return ''; 11 | // Handle function syntax like count(field) - these are leaf nodes 12 | if (name.match(/^\w+\(.+\)$/)) return name; 13 | const subFields = getField(node[name]); 14 | return subFields !== '' ? `${name}.${subFields}` : name; 15 | } 16 | 17 | export function getComparator(node: Record): string { 18 | return getNodeName(get(node, getField(node))); 19 | } 20 | 21 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 22 | export function fieldToFilter(field: string, operator: string, value: any): Record { 23 | return fieldToFilterR(field.split('.')); 24 | 25 | function fieldToFilterR(sections: string[]): Record { 26 | const section = sections.shift(); 27 | 28 | if (section !== undefined) { 29 | return { 30 | [section]: fieldToFilterR(sections), 31 | }; 32 | } else { 33 | return { 34 | [operator]: value, 35 | }; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/parseFilter.ts: -------------------------------------------------------------------------------- 1 | export type ObjectType = Record; 2 | 3 | export function parseFilter(obj: ObjectType | null, currentItem: ObjectType): any { 4 | return deepParse(obj, currentItem); 5 | } 6 | 7 | function deepParse(obj: ObjectType | null, currentItem: ObjectType): any { 8 | if (Array.isArray(obj)) { 9 | return obj.map(val => { 10 | return isObjectLike(val) ? deepParse(val, currentItem) : parseValue(val, currentItem); 11 | }); 12 | } else if (isObjectLike(obj)) { 13 | const res: ObjectType = {}; 14 | 15 | for (const key in obj) { 16 | const val = obj[key]; 17 | 18 | if (isObjectLike(val)) { 19 | res[key] = deepParse(val, currentItem); 20 | } else { 21 | res[key] = parseValue(val, currentItem); 22 | } 23 | } 24 | 25 | return res; 26 | } else { 27 | return obj; 28 | } 29 | } 30 | 31 | function parseValue(value: any, currentItem: ObjectType) { 32 | if (value === 'true') return true; 33 | if (value === 'false') return false; 34 | if (value === 'null' || value === 'NULL') return null; 35 | if (typeof value === 'string' && value.startsWith('$CURRENT_ITEM.')) { 36 | return get(currentItem, value.replace('$CURRENT_ITEM.', ''), null); 37 | } 38 | return value; 39 | } 40 | 41 | function isObjectLike(value: any) { 42 | return value && typeof value === 'object'; 43 | } 44 | 45 | function get(obj: ObjectType | any[], path: string, defaultValue: any): any { 46 | const [key, ...follow] = path.split('.'); 47 | const result = Array.isArray(obj) ? obj.map(entry => entry[key!]) : obj?.[key!]; 48 | if (follow.length > 0) { 49 | return get(result, follow.join('.'), defaultValue); 50 | } 51 | return result ?? defaultValue; 52 | } 53 | -------------------------------------------------------------------------------- /filters-display/src/display.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 33 | 34 | 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { defineInterface } from '@directus/extensions-sdk'; 2 | import InterfaceComponent from './interface.vue'; 3 | 4 | export default defineInterface({ 5 | id: 'filters', 6 | name: 'Filters&Rules', 7 | description: 'A filter interface for creating rules on given properties', 8 | icon: 'search', 9 | component: InterfaceComponent, 10 | types: ['json'], 11 | group: 'selection', 12 | options: [ 13 | { 14 | field: 'useCollection', 15 | type: 'string', 16 | name: '$t:collection', 17 | meta: { 18 | interface: 'system-collection', 19 | options: { 20 | includeSystem: true, 21 | includeSingleton: false, 22 | }, 23 | width: 'full', 24 | note: 'Select a collection to use its fields as the schema. If not set, custom properties below will be used.', 25 | }, 26 | }, 27 | { 28 | field: 'properties', 29 | name: 'Custom Properties', 30 | type: 'json', 31 | meta: { 32 | interface: 'code', 33 | options: { 34 | language: 'json', 35 | template: JSON.stringify( 36 | { 37 | name: { 38 | name: 'Full name', 39 | type: 'string', 40 | operators: ['eq', 'neq'], 41 | }, 42 | age: 'integer', 43 | gender: { 44 | type: 'string', 45 | choices: [ 46 | { 47 | text: 'Male', 48 | value: 'male', 49 | }, 50 | { 51 | text: 'Female', 52 | value: 'female', 53 | }, 54 | ], 55 | }, 56 | country: { 57 | type: 'string', 58 | choices: '$COUNTRIES', 59 | }, 60 | meta: { 61 | now: 'dateTime', 62 | active: 'boolean', 63 | }, 64 | }, 65 | null, 66 | 4 67 | ), 68 | }, 69 | note: 'Custom schema definition. Only used if no collection is selected above.', 70 | }, 71 | schema: { 72 | default_value: `{}`, 73 | }, 74 | }, 75 | ], 76 | }); 77 | -------------------------------------------------------------------------------- /src/intl.ts: -------------------------------------------------------------------------------- 1 | const _cache: Record = {}; 2 | 3 | function IntlInfo(lang = 'en', includeCurrencies = true) { 4 | if (_cache[lang]) { 5 | return _cache[lang]; 6 | } 7 | 8 | const countryNames = new Intl.DisplayNames([lang], { type: 'region', fallback: 'none' }); 9 | const languageNames = new Intl.DisplayNames([lang], { type: 'language', fallback: 'none' }); 10 | const currencyNames = new Intl.DisplayNames([lang], { type: 'currency', fallback: 'none' }); 11 | 12 | const countries: Record = {}; 13 | const languages: Record = {}; 14 | const currencies: Record = {}; 15 | 16 | let i, j, k, codeU, codeL, codeC, country, language, currency; 17 | 18 | if (!_cache.$) { 19 | _cache.$ = { 20 | U: [], 21 | L: [], 22 | C: [], 23 | }; 24 | 25 | const alpha: string[] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); 26 | 27 | for (i = 0; i < 26; ++i) { 28 | for (j = 0; j < 26; ++j) { 29 | if (alpha[i] && alpha[j]) { 30 | codeU = alpha[i] + alpha[j]!; 31 | 32 | country = countryNames.of(codeU); 33 | if (country) { 34 | _cache.$.U.push(codeU); 35 | countries[codeU] = country; 36 | } 37 | 38 | codeL = codeU.toLowerCase(); 39 | language = languageNames.of(codeL); 40 | if (language) { 41 | _cache.$.L.push(codeL); 42 | languages[codeL] = language; 43 | } 44 | 45 | if (includeCurrencies) { 46 | for (k = 0; k < 26; ++k) { 47 | codeC = codeU + alpha[k]; 48 | currency = currencyNames.of(codeC); 49 | if (currency) { 50 | _cache.$.C.push(codeC); 51 | currencies[codeC] = currency; 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } else { 59 | for (i = 0; i < _cache.$.U.length; ++i) { 60 | codeU = _cache.$.U[i]; 61 | countries[codeU] = countryNames.of(codeU); 62 | } 63 | for (j = 0; j < _cache.$.L.length; ++j) { 64 | codeL = _cache.$.L[j]; 65 | languages[codeL] = languageNames.of(codeL); 66 | } 67 | for (k = 0; k < _cache.$.C.length; ++k) { 68 | codeC = _cache.$.C[k]; 69 | currencies[codeC] = currencyNames.of(codeC); 70 | } 71 | } 72 | 73 | return (_cache[lang] = { 74 | countries, 75 | languages, 76 | currencies, 77 | }); 78 | } 79 | 80 | IntlInfo._cache = _cache; 81 | export default IntlInfo; 82 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash-es'; 2 | import type { Filter } from 'rule-filter-validator'; 3 | 4 | export function getNodeName(node: Filter): string { 5 | return Object.keys(node)[0] as string; 6 | } 7 | 8 | export function getField(node: Record): string { 9 | const name = getNodeName(node); 10 | if (name.startsWith('_')) return ''; 11 | // Handle function syntax like count(field) - these are leaf nodes 12 | if (name.match(/^\w+\(.+\)$/)) return name; 13 | const subFields = getField(node[name]); 14 | return subFields !== '' ? `${name}.${subFields}` : name; 15 | } 16 | 17 | export function getComparator(node: Record): string { 18 | return getNodeName(get(node, getField(node))); 19 | } 20 | 21 | export function fieldToFilter(field: string, operator: string, value: any): Record { 22 | return fieldToFilterR(field.split('.')); 23 | 24 | function fieldToFilterR(sections: string[]): Record { 25 | const section = sections.shift(); 26 | 27 | if (section !== undefined) { 28 | return { 29 | [section]: fieldToFilterR(sections), 30 | }; 31 | } else { 32 | return { 33 | [operator]: value, 34 | }; 35 | } 36 | } 37 | } 38 | 39 | // Find a field by its full path (e.g., "person.dob"), navigating through groups and relationships 40 | export function findFieldByPath(obj: any, path: string): any | null { 41 | if (!obj || !path || typeof path !== 'string') return null; 42 | 43 | const parts = path.split('.'); 44 | if (parts.length === 0) return null; 45 | 46 | // Helper to search within a single object level 47 | function searchAtLevel(level: any, remainingParts: string[]): any | null { 48 | if (!level || typeof level !== 'object' || remainingParts.length === 0) return null; 49 | 50 | const part = remainingParts[0]; 51 | if (!part) return null; 52 | 53 | // First, check if the field exists directly at this level 54 | if (Object.prototype.hasOwnProperty.call(level, part)) { 55 | const found = level[part]; 56 | // If this is the last part, return it 57 | if (remainingParts.length === 1) { 58 | return found; 59 | } 60 | // Otherwise, continue searching within this found object 61 | return searchAtLevel(found, remainingParts.slice(1)); 62 | } 63 | 64 | // If not found directly, check groups and relationships 65 | for (const key of Object.keys(level)) { 66 | // Skip internal metadata keys 67 | if (key.startsWith('__')) continue; 68 | 69 | const val = level[key]; 70 | if (!val || typeof val !== 'object') continue; 71 | 72 | // Check if this is a group - search within it 73 | if (val.__isGroup === true) { 74 | const foundInGroup = searchAtLevel(val, remainingParts); 75 | if (foundInGroup) return foundInGroup; 76 | } 77 | // Check if this key matches the part we're looking for 78 | // (could be a relationship or regular field) 79 | else if (key === part) { 80 | // Found the part - continue searching within it 81 | return searchAtLevel(val, remainingParts.slice(1)); 82 | } 83 | } 84 | 85 | return null; 86 | } 87 | 88 | return searchAtLevel(obj, parts); 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom filter & rule Interface - for Directus 9&10 2 | 3 | This is a custom filter & rule interface for Directus 9&10. It allows you to setup an interface with defined properties that users can then select and add values and conditions for. 4 | 5 | ![preview](https://raw.githubusercontent.com/u12206050/directus-extension-filters-interface/main/preview.png) 6 | 7 | Using the [Rule Filter Validator](https://github.com/u12206050/rule-filter-validator) library you can validate and test the rules stored in your frontend and backend. 8 | 9 | Support for `$NOW` and field functions such as `year()` and `count()` is supported in [Rule Filter Validator](https://github.com/u12206050/rule-filter-validator) since version `1.5.0` 10 | 11 | ## Install 12 | 13 | `npm install directus-extension-filters-interface` 14 | 15 | OR 16 | 17 | Copy the `index.js` file from the dist folder into your project eg. `PATH_TO_DIRECTUS_PROJECT/extensions/interfaces/filters` 18 | 19 | ### Config 20 | 21 | When setting up you as the developer should add the properties that can be chosen according to the following schema: 22 | 23 | ```ts 24 | type Properties = { 25 | [Property: string]: string | Property[] | AdvProp | RelationProp; 26 | }; 27 | 28 | type PropType = 'string' | 'boolean' | 'integer' | 'timestamp' | 'text'; 29 | 30 | type AdvProp = { 31 | type: PropType; 32 | label?: string; 33 | choices?: Choice[]; 34 | operators?: FieldFilterOperator[]; 35 | }; 36 | 37 | type RelationProp = { 38 | type: PropType; 39 | label: string; 40 | field: string; 41 | template: string; 42 | interface: string; 43 | collection: string; 44 | filter?: Filter; 45 | }; 46 | ``` 47 | 48 | ### Example 49 | 50 | ```json 51 | { 52 | "org": { 53 | "id": { 54 | "type": "integer", 55 | "field": "id", 56 | "template": "{{name}}", 57 | "interface": "select-dropdown-m2o", 58 | "collection": "organizations", 59 | "filter": { 60 | "country": { 61 | /* It supports getting top level values from the current item */ 62 | "_eq": "$CURRENT_ITEM.country" 63 | } 64 | } 65 | }, 66 | "country": { 67 | "type": "string", 68 | "choices": "$COUNTRIES" 69 | } 70 | }, 71 | "person": { 72 | "id": "integer", 73 | "age": "integer", 74 | "dob": "dateTime", // Will allow for selecting year, month, day, minute and second 75 | "active": "boolean", 76 | "gender": { 77 | "type": "string", 78 | "choices": [ 79 | { 80 | "text": "Male", 81 | "value": "male" 82 | }, 83 | { 84 | "text": "Female", 85 | "value": "female" 86 | } 87 | ], 88 | // (optional) Override default operators for this type (@see @directus/utils/getFilterOperatorsForType) 89 | "operators": ["eq", "neq"] 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | ## Develop 96 | 97 | - Clone repo 98 | - `npm install` 99 | - Update the path in package.json from `dist/index.js` to 100 | `PATH_TO_DIRECTUS_PROJECT/extensions/interfaces/filters` 101 | - `npm run dev` 102 | 103 | ### Reduced build size: 104 | 105 | Update the @directus/extensions/dist/index.js with 106 | `var APP_SHARED_DEPS = ["@directus/extensions-sdk", "vue", "vue-router", "vue-i18n", "pinia", "zod", "joi", "sortablejs"];` 107 | 108 | PRs are welcome 109 | -------------------------------------------------------------------------------- /src/components/input-component.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 179 | 180 | 238 | -------------------------------------------------------------------------------- /src/components/input-group.vue: -------------------------------------------------------------------------------- 1 | 159 | 160 | 190 | 191 | 192 | 193 | 227 | -------------------------------------------------------------------------------- /RELATIONSHIP_FILTERS_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Relationship Filters: `_none` Operator and `count()` Function 2 | 3 | ## Overview 4 | 5 | This extension supports advanced filtering for One-to-Many (o2m) and Many-to-Many (m2m) relationship fields according to the [Directus filter rules documentation](https://directus.io/docs/guides/connect/filter-rules). 6 | 7 | ### Features 8 | 9 | - **`count()` function**: Filter by the number of related items 10 | - **`_none` operator**: Filter where none of the related items match conditions 11 | - **Only for o2m/m2m**: Both features only work with relationships that can have multiple items 12 | - **Default behavior**: Filtering on nested fields uses `_some` (at least one matches) 13 | 14 | ## How to Use 15 | 16 | ### For o2m/m2m Relationships 17 | 18 | When you expand a One-to-Many or Many-to-Many relationship field in the "Add Filter" dropdown: 19 | 20 | ``` 21 | organizations ▶ 22 | ├─ # Count ← Filter by number of organizations 23 | ├─ ∅ None ← Filter where no organizations match 24 | ├─ ───────── 25 | ├─ name ← Default: at least one matches 26 | ├─ status 27 | └─ ... 28 | ``` 29 | 30 | ## Count Function 31 | 32 | ### What It Does 33 | 34 | Filters items based on the **number** of related items. 35 | 36 | ### How to Use 37 | 38 | 1. Expand a relationship field (e.g., `organizations`) 39 | 2. Select **# Count** 40 | 3. Choose an operator (equals, greater than, less than, etc.) 41 | 4. Enter a number 42 | 43 | ### Examples 44 | 45 | **Example 1: Exactly 10 Organizations** 46 | 47 | ```json 48 | { 49 | "count(organizations)": { 50 | "_eq": 10 51 | } 52 | } 53 | ``` 54 | 55 | **Example 2: More Than 5 Organizations** 56 | 57 | ```json 58 | { 59 | "count(organizations)": { 60 | "_gt": 5 61 | } 62 | } 63 | ``` 64 | 65 | **Example 3: At Least 1 Organization (Has Organizations)** 66 | 67 | ```json 68 | { 69 | "count(organizations)": { 70 | "_gte": 1 71 | } 72 | } 73 | ``` 74 | 75 | **Example 4: No Organizations** 76 | 77 | ```json 78 | { 79 | "count(organizations)": { 80 | "_eq": 0 81 | } 82 | } 83 | ``` 84 | 85 | ## None Operator 86 | 87 | ### What It Does 88 | 89 | Filters items where **none** of the related items match the specified conditions. 90 | 91 | ### How to Use 92 | 93 | 1. Expand a relationship field (e.g., `organizations`) 94 | 2. Select **∅ None** 95 | 3. A red group appears: "Organizations - None of the following:" 96 | 4. Add filters for the related collection's fields 97 | 98 | ### Examples 99 | 100 | **Example 1: No Organizations Named "Acme"** 101 | 102 | Visual: 103 | 104 | ``` 105 | 🔴 Organizations - None of the following: 106 | └─ Name Equals "Acme" 107 | ``` 108 | 109 | Generated Filter: 110 | 111 | ```json 112 | { 113 | "organizations": { 114 | "_none": { 115 | "name": { 116 | "_eq": "Acme" 117 | } 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | **Example 2: No Active Organizations** 124 | 125 | Visual: 126 | 127 | ``` 128 | 🔴 Organizations - None of the following: 129 | └─ Status Equals "active" 130 | ``` 131 | 132 | Generated Filter: 133 | 134 | ```json 135 | { 136 | "organizations": { 137 | "_none": { 138 | "status": { 139 | "_eq": "active" 140 | } 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | **Example 3: Complex - No Large Active Organizations** 147 | 148 | Visual: 149 | 150 | ``` 151 | 🔴 Organizations - None of the following: 152 | ├─ Status Equals "active" 153 | └─ Size Equals "large" 154 | ``` 155 | 156 | Generated Filter: 157 | 158 | ```json 159 | { 160 | "organizations": { 161 | "_none": { 162 | "status": { "_eq": "active" }, 163 | "size": { "_eq": "large" } 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | **Note**: When multiple filters are added to a `_none` group, they are merged into a flat object. The `_and` operator is not supported inside `_none` groups. 170 | 171 | ## Combining Count and None 172 | 173 | You can combine these features with other filters for powerful queries! 174 | 175 | **Example: Has Between 1-5 Organizations, None of Which Are Active** 176 | 177 | ```json 178 | { 179 | "_and": [ 180 | { 181 | "count(organizations)": { 182 | "_between": [1, 5] 183 | } 184 | }, 185 | { 186 | "organizations": { 187 | "_none": { 188 | "status": { 189 | "_eq": "active" 190 | } 191 | } 192 | } 193 | } 194 | ] 195 | } 196 | ``` 197 | 198 | ## Comparison Table 199 | 200 | | Filter Type | Use Case | Example | 201 | | ---------------------------------- | -------------------- | -------------------------------------- | 202 | | **Nested Field** (default `_some`) | At least one matches | organizations → status = "active" | 203 | | **count()** | Filter by number | count(organizations) ≥ 5 | 204 | | **\_none** | None match condition | organizations None → status = "active" | 205 | 206 | ## Technical Details 207 | 208 | ### Count Function 209 | 210 | - **Syntax**: `count(field_name)` 211 | - **Returns**: Integer (number of related items) 212 | - **Supported Operators**: All numeric operators (\_eq, \_neq, \_gt, \_gte, \_lt, \_lte, \_between, \_nbetween) 213 | - **Type**: Automatically detected as `integer` type for proper input handling 214 | 215 | ### None Operator 216 | 217 | - **Structure**: Field with `_none` operator containing nested filters 218 | - **UI Rendering**: Logical group (like \_and/\_or) with scoped field tree 219 | - **Path Management**: Automatic stripping/adding of relationship prefix 220 | - **Color**: Red to distinguish from other logical groups 221 | - **Toggle**: Cannot be toggled (locked to relationship context) 222 | 223 | ### Files Modified 224 | 225 | 1. **`src/interface.vue`** 226 | 227 | - Added `[Count]` and `[None]` options to o2m/m2m relationship dropdowns 228 | - Handles `.$count` suffix to create count() filters 229 | - Handles `.$none` suffix to create \_none groups 230 | 231 | 2. **`src/components/nodes.vue`** 232 | 233 | - Detects `_none` groups and renders as logical groups 234 | - Handles `count()` function display in field previews 235 | - Implements path stripping/adding for None groups 236 | 237 | 3. **`src/components/input-group.vue`** 238 | 239 | - Detects `count()` functions and uses integer input type 240 | 241 | 4. **`src/utils.ts` & `filters-display/src/utils.ts`** 242 | 243 | - Updated `getField()` to recognize function syntax 244 | 245 | 5. **`filters-display/src/components/nodes.vue`** 246 | - Handles display of both `count()` and `_none` features 247 | 248 | ## Usage Tips 249 | 250 | 1. **Use `count()` for quantity checks** 251 | 252 | - "Has at least 3 orders" 253 | - "Has exactly 0 organizations" 254 | - "Has between 5-10 items" 255 | 256 | 2. **Use `_none` for exclusion conditions** 257 | 258 | - "No pending orders" 259 | - "No inactive organizations" 260 | - "No expired subscriptions" 261 | 262 | 3. **Use nested fields (default) for inclusion** 263 | 264 | - "Has at least one active organization" (just use: organizations → status = active) 265 | 266 | 4. **Combine for complex logic** 267 | - "Has 1-3 organizations, none of which are pending" 268 | -------------------------------------------------------------------------------- /filters-display/src/components/nodes.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 190 | 191 | 291 | -------------------------------------------------------------------------------- /src/components/select-dropdown-m2o.vue: -------------------------------------------------------------------------------- 1 | 254 | 255 | 316 | 317 | 366 | 367 | 372 | -------------------------------------------------------------------------------- /src/interface.vue: -------------------------------------------------------------------------------- 1 | 331 | 332 | 409 | 410 | 424 | 425 | 518 | -------------------------------------------------------------------------------- /src/components/nodes.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 852 | 853 | 1011 | --------------------------------------------------------------------------------