import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit'; import {FormControlController} from "../../internal/form"; import {litToHTML} from "../../utilities/lit-to-html"; import {property, query} from 'lit/decorators.js'; import ZincElement from '../../internal/zinc-element'; import ZnButton from "../button"; import ZnInput from "../input"; import ZnOption from "../option"; import ZnSelect from "../select"; import type {ZincFormControl} from '../../internal/zinc-element'; import type {ZnChangeEvent} from "../../events/zn-change"; import type {ZnInputEvent} from "../../events/zn-input"; import styles from './query-builder.scss'; export type QueryBuilderData = QueryBuilderItem[]; export interface QueryBuilderItem { id: string; name: string; type?: QueryBuilderType; options?: QueryBuilderOptions; operators: QueryBuilderOperators[]; maxOptionsVisible?: string; } export type QueryBuilderType = 'bool' | 'boolean' | 'date' | 'number'; export interface QueryBuilderOptions { [key: string | number]: string | number; } export enum QueryBuilderOperators { Eq = 'eq', Neq = 'neq', Eqi = 'eqi', Neqi = 'neqi', Before = 'before', After = 'after', In = 'in', Nin = 'nin', MatchPhrasePre = 'matchphrasepre', NMatchPhrasePre = 'nmatchphrasepre', MatchPhrase = 'matchphrase', NMatchPhrase = 'nmatchphrase', Match = 'match', NMatch = 'nmatch', Contains = 'contains', DoesNotContain = 'doesnotcontain', Starts = 'starts', NStarts = 'nstarts', Ends = 'ends', NEnds = 'nends', Wild = 'wild', NWild = 'nwild', Like = 'like', NLike = 'nlike', Fuzzy = 'fuzzy', NFuzzy = 'nfuzzy', Gte = 'gte', Gt = 'gt', Lt = 'lt', Lte = 'lte' } const operatorText: { [key in QueryBuilderOperators]: string } = { [QueryBuilderOperators.Eq]: 'Equals', [QueryBuilderOperators.Neq]: 'Not Equals', [QueryBuilderOperators.Eqi]: 'Equals (Insensitive)', [QueryBuilderOperators.Neqi]: 'Not Equals (Insensitive)', [QueryBuilderOperators.Before]: 'Was Before', [QueryBuilderOperators.After]: 'Was After', [QueryBuilderOperators.In]: 'In', [QueryBuilderOperators.Nin]: 'Not In', [QueryBuilderOperators.MatchPhrasePre]: 'Match Phrase Prefix', [QueryBuilderOperators.NMatchPhrasePre]: 'Does Not Match Phrase Prefix', [QueryBuilderOperators.MatchPhrase]: 'Match Phrase', [QueryBuilderOperators.NMatchPhrase]: 'Does Not Match Phrase', [QueryBuilderOperators.Match]: 'Match', [QueryBuilderOperators.NMatch]: 'Does Not Match', [QueryBuilderOperators.Contains]: 'Contains', [QueryBuilderOperators.DoesNotContain]: 'Does Not Contain', [QueryBuilderOperators.Starts]: 'Starts With', [QueryBuilderOperators.NStarts]: 'Does Not Start With', [QueryBuilderOperators.Ends]: 'Ends With', [QueryBuilderOperators.NEnds]: 'Does Not End With', [QueryBuilderOperators.Wild]: 'Wildcard Match', [QueryBuilderOperators.NWild]: 'Does Not Match Wildcard', [QueryBuilderOperators.Like]: 'Like Match With', [QueryBuilderOperators.NLike]: 'Does Not Like Match With', [QueryBuilderOperators.Fuzzy]: 'Fuzzy Match With', [QueryBuilderOperators.NFuzzy]: 'Does Not Match Fuzzy With', [QueryBuilderOperators.Gte]: 'Greater Than or Equals', [QueryBuilderOperators.Gt]: 'Greater Than', [QueryBuilderOperators.Lt]: 'Less Than', [QueryBuilderOperators.Lte]: 'Less Than or Equals', }; export interface CreatedRule { id: string; name: string; operator: string; value: string; } /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/query-builder * @status experimental * @since 1.0 * * @dependency zn-button * @dependency zn-input * @dependency zn-option * @dependency zn-select * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnQueryBuilder extends ZincElement implements ZincFormControl { static styles: CSSResultGroup = unsafeCSS(styles); static dependencies = { 'zn-button': ZnButton, 'zn-input': ZnInput, 'zn-option': ZnOption, 'zn-select': ZnSelect, }; private _selectedRules: Map = new Map(); private _formController: FormControlController = new FormControlController(this, {}); private _previousOperator: QueryBuilderOperators; @query('.query-builder') container: HTMLDivElement; @query('.add-rule') addRule: ZnSelect; @query('input#main-input') input: HTMLInputElement; @property({type: Array}) filters: QueryBuilderData = []; @property({type: Boolean}) dropdown: boolean = false; @property() name: string; @property() value: PropertyKey; @property({ attribute: 'show-values', converter: { fromAttribute: (value: string) => value.split(' '), toAttribute: (value: string[]) => value.join(' ') } }) showValues: string[] = []; get validationMessage(): string { return ''; } get validity(): ValidityState { return this.input?.validity; } protected firstUpdated(_changedProperties: PropertyValues) { super.firstUpdated(_changedProperties); if (this.showValues) { this.showValues.forEach(item => { this._addRule(null, item); }); } this._handleChange(); this._formController.updateValidity(); } render() { return html`
${this.filters && this.filters.map(item => html` ${item.name.charAt(0).toUpperCase() + item.name.slice(1)} `)}
`; } private _handleChange() { const data: object[] = []; [...this._selectedRules].forEach(([, value]) => { data.push({ key: value.id, comparator: value.operator, value: value.value }); }); this.value = btoa(JSON.stringify(data)); this.emit("zn-change"); } private _addRule(event: Event | null, value: string, pos?: number) { const target = event?.target as ZnSelect; const id = value ? value : target.value; if (id === '') return; const filter: QueryBuilderItem | undefined = this.filters.find(item => item.id === id); if (filter === undefined) return; const uniqueId = Math.random().toString(36).substring(7); this._selectedRules.set(uniqueId, { id: filter.id, name: filter.name, operator: filter.operators.length > 0 ? filter.operators[0] : QueryBuilderOperators.Eq, value: '' }); const select = html` ${this.filters.map((item: QueryBuilderItem) => { return html` ${item.name.charAt(0).toUpperCase() + item.name.slice(1)} `; })} `; let comparator = null; // if operators are defined and only equals remove the comparator const isBoolFilterOnly = filter.operators.length === 1 && filter.operators[0] === QueryBuilderOperators.Eq && (filter.type === 'bool' || filter.type === 'boolean'); if (!isBoolFilterOnly) { comparator = html` ${filter.operators.map((item: QueryBuilderOperators) => { return html` ${operatorText[item as QueryBuilderOperators]}`; })} `; } const remove = html` `; const selectedComparator = filter.operators.length > 0 ? filter.operators[0] : QueryBuilderOperators.Eq; const wrapper = html`
${this._createInput(filter, uniqueId, selectedComparator)} ${remove}
`; const rowElement = html`
${select} ${comparator} ${wrapper}
`; const row = litToHTML(rowElement); if (!row) return; if (pos !== undefined) { this.container.insertBefore(row, this.container.children[pos]); } else { this.container.insertBefore(row, this.addRule); } // Reset back to default placeholder this.addRule.value = ''; this.addRule.displayLabel = ''; this.addRule.selectedOptions[0].selected = false; // Auto scroll to keep the add rule select in view if (target === this.addRule && this.parentElement?.classList.contains('dropdown__query-builder')) { const parentElement = this.parentElement; requestAnimationFrame(() => { parentElement.scrollTop = parentElement.scrollHeight; }); } this._handleChange(); } private _createInput(filter: QueryBuilderItem, uniqueId: string, selectedComparator: QueryBuilderOperators) { let input: ZnSelect | ZnInput | null; switch (filter.type) { case 'bool': case 'boolean': { input = this._createBooleanInput(uniqueId); break; } case 'number': { input = this._createNumberInput(uniqueId); break; } case 'date': { input = this._createDateInput(uniqueId); break; } default: { input = filter.options ? this._createSelectInput(uniqueId, filter, selectedComparator) : this._createDefaultInput(uniqueId); break; } } return input; } private _changeValueInput(uniqueId: string, changeEvent: ZnChangeEvent, filter: QueryBuilderItem) { // Only comparisons with options need to change input if (!filter.options) return; const compareSelect = changeEvent.target as ZnSelect; // Comparison the same - no change if (compareSelect.value === this._previousOperator) return; const input: ZnInput | null | undefined = compareSelect.parentElement?.querySelector('.query-builder__value'); // Cannot find input if (!input) return; const parent = input?.parentElement as HTMLDivElement; parent.removeChild(input); const newInput = this._createInput(filter, uniqueId, compareSelect.value as QueryBuilderOperators); if (!newInput) return; parent.prepend(newInput); } private _createBooleanInput(uniqueId: string): ZnSelect | null { const input = html` True False `; return litToHTML(input); } private _createNumberInput(uniqueId: string): ZnInput | null { const input = html` `; return litToHTML(input); } private _createDateInput(uniqueId: string): ZnInput | null { const input = html` `; return litToHTML(input); } private _createSelectInput(uniqueId: string, filter: QueryBuilderItem, selectedComparator: QueryBuilderOperators): ZnSelect | null { const options: QueryBuilderOptions | undefined = this.filters.find(item => item.id === filter?.id)?.options; if (options === undefined) return null; const multiSelect = selectedComparator === QueryBuilderOperators.In || selectedComparator === QueryBuilderOperators.Nin; const input = html` ${Object.keys(options).map(key => html` ${options[key]} `)} `; return litToHTML(input); } private _createDefaultInput(uniqueId: string): ZnInput | null { const input = html` `; return litToHTML(input); } private _updateOperatorValue(id: string, event: ZnChangeEvent) { const filter = this._selectedRules.get(id); if (!filter) return; const select = event.target as ZnSelect; this._previousOperator = filter.operator as QueryBuilderOperators; filter.operator = select.value as QueryBuilderOperators; this._selectedRules.set(id, filter); this._handleChange(); } private _updateDateValue(id: string, event: Event | { target: ZnSelect | ZnInput | HTMLDivElement }) { const filter = this._selectedRules.get(id); if (!filter) return; const input = event.target as ZnSelect | ZnInput; const operator = filter.operator as QueryBuilderOperators; let timestamp: string; if (operator === QueryBuilderOperators.Eq || operator === QueryBuilderOperators.Neq) { timestamp = (Date.parse(input.value as string) / 1000).toString(); } else { // Dodgy logic to offset backend filter comparator values // Ref: backend/src/Infrastructure/Helpers/AdvancedFilterHelper.php:106 const multiplier = operator === QueryBuilderOperators.Before ? -1 : 1; timestamp = (Math.floor((Date.now() - Date.parse(input.value as string)) / 1000 / 60) * multiplier).toString(); } filter.value = timestamp as string; this._selectedRules.set(id, filter); this._handleChange(); } private _updateValue(id: string, event: Event | { target: ZnSelect | ZnInput | HTMLDivElement }) { const filter = this._selectedRules.get(id); if (!filter) return; const input = event.target as ZnSelect | ZnInput; filter.value = input.value as string; this._selectedRules.set(id, filter); this._handleChange(); } private updateInValue(id: string, event: Event) { const filter = this._selectedRules.get(id); if (!filter) return; const input = event.target as ZnSelect; filter.value = input.value as string; this._selectedRules.set(id, filter); this._handleChange(); } private _changeRule(id: string, event: ZnChangeEvent) { // remove the element from the dom const pos: number = this._getRulePosition(id); const select = event.target as ZnSelect; select.popup.active = false; select?.parentElement?.remove(); // recreate the element based on the selected value; this._removeRule(id, event); this._addRule(event, '', pos); } private _getRulePosition(id: string): number { const rules = this.container.querySelectorAll('.query-builder__row'); let position: number = -1; rules.forEach((item, index) => { if (item.id === id) { position = index; } }); return position; } private _removeRule(id: string, event: Event) { this._selectedRules.delete(id); const button = event.target as ZnButton; button?.closest('.query-builder__row')?.remove(); this._handleChange(); } clear() { this._selectedRules.clear(); this.value = ''; // remove all added rows const rows = this.container.querySelectorAll('.query-builder__row'); rows.forEach(row => { row.remove(); }); this.requestUpdate(); this._formController.updateValidity(); } reset() { // reset the form back to the initial value this.clear(); this.showValues.forEach(item => { this._addRule(null, item); }); this._handleChange(); this._formController.updateValidity(); } checkValidity(): boolean { return this.input.checkValidity(); } getForm(): HTMLFormElement | null { return this._formController.getForm(); } reportValidity(): boolean { return this.input.reportValidity(); } setCustomValidity(message: string): void { this.input.setCustomValidity(message); this._formController.updateValidity(); } }