@fleetbase/ember-ui
Version:
Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.
495 lines (422 loc) • 16.8 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { task, timeout } from 'ember-concurrency';
export default class QueryBuilderConditionsComponent extends Component {
conditionGroups = [];
constructor() {
super(...arguments);
this.initializeConditionGroups();
}
initializeConditionGroups() {
if (this.args.conditions?.length) {
// Convert flat conditions to grouped structure
this.conditionGroups = [
{
id: 'root',
isNested: false,
operator: 'and',
conditions: this.args.conditions,
},
];
} else {
this.conditionGroups = [];
}
}
/**
* Get all available columns including main table and joined columns
* This ensures conditions can only be applied to selected columns
*/
get availableColumns() {
// Use allSelectedColumns from parent if available, otherwise fall back to existing logic
if (this.args.allSelectedColumns?.length) {
return this.args.allSelectedColumns.map((column) => ({
...column,
table: column.table || this.args.table?.name,
full: column.full || `${column.table || this.args.table?.name}.${column.name}`,
label: column.label || column.name,
source: 'main',
}));
}
// Existing fallback logic
const columns = [];
// Add columns from main table (only selected ones)
if (this.args.selectedColumns?.length) {
this.args.selectedColumns.forEach((column) => {
columns.push({
...column,
table: column.table || this.args.table?.name,
full: column.full || `${column.table || this.args.table?.name}.${column.name}`,
label: column.label || column.name,
source: 'main',
});
});
}
// Add columns from joined tables (only selected ones)
if (this.args.joins?.length) {
this.args.joins.forEach((join) => {
if (join.selectedColumns?.length) {
join.selectedColumns.forEach((column) => {
columns.push({
...column,
table: join.table,
full: column.full || `${join.table}.${column.name}`,
label: column.label || `${join.label} - ${column.name}`,
source: 'joined',
});
});
}
});
}
return columns;
}
get hasConditions() {
return (this.conditionGroups ?? []).some((g) => (g?.conditions?.length ?? 0) > 0);
}
get conditionsSummary() {
if (!this.hasConditions) return 'No conditions';
const totalConditions = this.conditionGroups.reduce((total, group) => total + group.conditions.length, 0);
const groupCount = this.conditionGroups.filter((group) => group.isNested).length;
let summary = `${totalConditions} condition${totalConditions !== 1 ? 's' : ''}`;
if (groupCount > 0) {
summary += `, ${groupCount} group${groupCount !== 1 ? 's' : ''}`;
}
return summary;
}
get canAddConditions() {
return this.availableColumns.length > 0;
}
get conditionsMessage() {
if (!this.args.selectedColumns?.length && !this.args.joins?.length) {
return 'Select columns first to enable filtering';
}
if (!this.canAddConditions) {
return 'No selected columns available for filtering';
}
return null;
}
get booleanOptions() {
return [
{ value: true, label: 'True' },
{ value: false, label: 'False' },
];
}
getOperatorsForField(field) {
if (!field) return [];
const baseOperators = [
{ value: '=', label: 'equals', icon: 'equals' },
{ value: '!=', label: 'not equals', icon: 'not-equal' },
{ value: 'is_null', label: 'is empty', icon: 'ban' },
{ value: 'is_not_null', label: 'is not empty', icon: 'check' },
];
const stringOperators = [
{ value: 'like', label: 'contains', icon: 'search' },
{ value: 'not_like', label: 'does not contain', icon: 'search-minus' },
{ value: 'starts_with', label: 'starts with', icon: 'arrow-right' },
{ value: 'ends_with', label: 'ends with', icon: 'arrow-left' },
{ value: 'in', label: 'is one of', icon: 'list' },
{ value: 'not_in', label: 'is not one of', icon: 'list-slash' },
];
const numberOperators = [
{ value: '>', label: 'greater than', icon: 'greater-than' },
{ value: '<', label: 'less than', icon: 'less-than' },
{ value: '>=', label: 'greater than or equal', icon: 'greater-than-equal' },
{ value: '<=', label: 'less than or equal', icon: 'less-than-equal' },
{ value: 'between', label: 'between', icon: 'arrows-left-right' },
{ value: 'not_between', label: 'not between', icon: 'arrows-left-right-slash' },
{ value: 'in', label: 'is one of', icon: 'list' },
{ value: 'not_in', label: 'is not one of', icon: 'list-slash' },
];
const dateOperators = [
{ value: '>', label: 'after', icon: 'calendar-plus' },
{ value: '<', label: 'before', icon: 'calendar-minus' },
{ value: '>=', label: 'on or after', icon: 'calendar-check' },
{ value: '<=', label: 'on or before', icon: 'calendar-xmark' },
{ value: 'between', label: 'between dates', icon: 'calendar-range' },
{ value: 'not_between', label: 'not between dates', icon: 'calendar-range-slash' },
];
switch (field.type) {
case 'string':
case 'text':
return [...baseOperators, ...stringOperators];
case 'number':
case 'integer':
case 'decimal':
case 'float':
return [...baseOperators, ...numberOperators];
case 'date':
case 'datetime':
case 'timestamp':
return [...baseOperators, ...dateOperators];
case 'boolean':
return baseOperators.filter((op) => ['=', '!=', 'is_null', 'is_not_null'].includes(op.value));
default:
return baseOperators;
}
}
getOperatorIcon(operatorValue) {
const iconMap = {
'=': 'equals',
'!=': 'not-equal',
'>': 'greater-than',
'<': 'less-than',
'>=': 'greater-than-equal',
'<=': 'less-than-equal',
like: 'search',
not_like: 'search-minus',
in: 'list',
not_in: 'list-slash',
between: 'arrows-left-right',
not_between: 'arrows-left-right-slash',
is_null: 'ban',
is_not_null: 'check',
starts_with: 'arrow-right',
ends_with: 'arrow-left',
};
return iconMap[operatorValue] || 'question';
}
getInputTypeForField(field) {
if (!field) return 'text';
switch (field.type) {
case 'number':
case 'integer':
case 'decimal':
case 'float':
return 'number';
case 'date':
return 'date';
case 'datetime':
case 'timestamp':
return 'datetime-local';
case 'email':
return 'email';
case 'url':
return 'url';
default:
return 'text';
}
}
getValueOptionsForField(field) {
if (!field) return [];
// For enum fields, return the enum values
if (field.enum_values) {
return field.enum_values.map((value) => ({ value, label: value }));
}
// For status fields, return common status values
if (field.name.includes('status')) {
return ['active', 'inactive', 'pending', 'completed', 'cancelled', 'draft', 'published', 'archived', 'deleted'].map((value) => ({ value, label: value }));
}
// For boolean fields
if (field.type === 'boolean') {
return this.booleanOptions;
}
return [];
}
addCondition() {
// Add to root group or create root group if none exists
if (this.conditionGroups.length === 0) {
this.conditionGroups = [
{
id: 'root',
isNested: false,
operator: 'and',
conditions: [],
},
];
}
this.addConditionToGroup(0);
}
addConditionToGroup(groupIndex) {
const newCondition = {
id: Date.now() + Math.random(),
field: null,
operator: null,
value: null,
logicalOperator: 'and',
};
const updatedGroups = [...this.conditionGroups];
updatedGroups[groupIndex] = {
...updatedGroups[groupIndex],
conditions: [...updatedGroups[groupIndex].conditions, newCondition],
};
this.conditionGroups = updatedGroups;
this.notifyChange();
}
addConditionGroup() {
const newGroup = {
id: Date.now() + Math.random(),
isNested: true,
operator: 'and',
conditions: [
{
id: Date.now() + Math.random() + 1,
field: null,
operator: null,
value: null,
logicalOperator: 'and',
},
],
};
this.conditionGroups = [...this.conditionGroups, newGroup];
this.notifyChange();
}
removeCondition(groupIndex, conditionIndex) {
const updatedGroups = [...this.conditionGroups];
updatedGroups[groupIndex] = {
...updatedGroups[groupIndex],
conditions: updatedGroups[groupIndex].conditions.filter((_, i) => i !== conditionIndex),
};
// Remove empty nested groups
if (updatedGroups[groupIndex].isNested && updatedGroups[groupIndex].conditions.length === 0) {
updatedGroups.splice(groupIndex, 1);
}
this.conditionGroups = updatedGroups;
this.notifyChange();
}
removeConditionGroup(groupIndex) {
this.conditionGroups = this.conditionGroups.filter((_, i) => i !== groupIndex);
this.notifyChange();
}
updateConditionField(groupIndex, conditionIndex, field) {
this.updateCondition(groupIndex, conditionIndex, (c) => {
c.field = field;
c.operator = null;
c.value = null;
});
}
updateConditionOperator(groupIndex, conditionIndex, operator) {
this.updateCondition(groupIndex, conditionIndex, (c) => {
if (typeof operator === 'string') {
c.logicalOperator = operator;
return;
}
c.operator = operator;
const key = operator?.value;
if (key === 'is_null' || key === 'is_not_null') c.value = null;
else if (key === 'between' || key === 'not_between') c.value = [null, null];
else if (key === 'in' || key === 'not_in') c.value = [];
else c.value = null;
});
}
updateConditionValue(groupIndex, conditionIndex, value) {
const group = this.conditionGroups[groupIndex];
const cond = group?.conditions?.[conditionIndex];
if (!cond) return;
if (value && typeof value === 'object' && 'target' in value) {
cond.value = value.target.value;
} else if (isArray(value)) {
cond.value = [...value]; // replace the *value array* if needed
} else {
cond.value = value;
}
this.notifyDebounced.perform();
}
updateConditionRangeValue(groupIndex, conditionIndex, rangeIndex, event) {
this.updateCondition(groupIndex, conditionIndex, (c) => {
const next = isArray(c.value) ? [...c.value] : [null, null];
next[rangeIndex] = event.target.value;
c.value = next; // replace value array, not the condition object
});
}
updateGroupOperator(groupIndex, operator) {
// same immutable pattern for the group object
const groups = [...this.conditionGroups];
groups[groupIndex] = { ...groups[groupIndex], operator };
this.conditionGroups = groups;
this.notifyChange();
}
reorderConditionGroups({ sourceList, sourceIndex, targetList, targetIndex }) {
// no change? bail
if (sourceList === targetList && sourceIndex === targetIndex) return;
// mutate the EmberArray in-place (per README)
const item = sourceList.objectAt(sourceIndex);
sourceList.removeAt(sourceIndex);
targetList.insertAt(targetIndex, item);
// ensure Glimmer sees a change even if it misses EmberArray observers
this.conditionGroups = [...this.conditionGroups];
this.notifyChange();
}
reorderConditions(groupIndex, { sourceList, sourceIndex, targetList, targetIndex }) {
if (sourceList === targetList && sourceIndex === targetIndex) return;
const item = sourceList.objectAt(sourceIndex);
sourceList.removeAt(sourceIndex);
targetList.insertAt(targetIndex, item);
// force a tick for Glimmer just in case
this.conditionGroups = [...this.conditionGroups];
this.notifyChange();
}
/**
* Validate existing conditions when available columns change
*/
validateConditions() {
if (!this.availableColumns.length) {
// Clear all conditions if no columns available
if (this.hasConditions) {
this.conditionGroups = [];
this.notifyChange();
}
return;
}
// Remove conditions for fields that are no longer available
let hasChanges = false;
const updatedGroups = this.conditionGroups
.map((group) => {
const validConditions = group.conditions.filter((condition) => {
if (!condition.field) return true; // Keep incomplete conditions
return this.availableColumns.some((col) => col.full === condition.field.full);
});
if (validConditions.length !== group.conditions.length) {
hasChanges = true;
}
return {
...group,
conditions: validConditions,
};
})
.filter((group) => group.conditions.length > 0 || !group.isNested);
if (hasChanges) {
this.conditionGroups = updatedGroups;
this.notifyChange();
}
}
updateCondition(groupIndex, conditionIndex, mutate) {
// clone containers (so Glimmer sees a change)
const groups = [...this.conditionGroups];
const group = { ...groups[groupIndex] };
const conditions = [...group.conditions];
// IMPORTANT: keep item identity
const condition = conditions[conditionIndex]; // do not clone
// allow caller to mutate the existing condition object
mutate(condition);
// write back container identities
group.conditions = conditions;
groups[groupIndex] = group;
this.conditionGroups = groups;
this.notifyChange?.();
}
notifyChange() {
if (this.args.onChange) {
const flatConditions = (this.conditionGroups ?? []).reduce((acc, group) => {
const conds = isArray(group?.conditions) ? group.conditions : [];
return acc.concat(conds);
}, []);
this.args.onChange(flatConditions, this.conditionGroups ?? []);
}
}
*notifyDebounced() {
yield timeout(200);
this.notifyChange();
}
}