Add Feature Query List View Filter

Now you can use legacy query filter in list view pages
This commit is contained in:
Moises E. Puyosa 2024-01-10 11:59:20 -05:00 committed by Jack Anderson
parent a76dfef14f
commit 7cbf033208
19 changed files with 843 additions and 35 deletions

View file

@ -35,6 +35,7 @@ services:
$listViewTableActions: '%module.listview.table_action%'
$listViewLineActionsLimits: '%module.listview.line_actions_limits%'
$listViewSidebarWidgets: '%module.listview.sidebar_widgets%'
$listViewUrlQueryFilterMapping: '%module.listview.url_query_filter_mapping%'
$listViewColumnLimits: '%module.listview.column_limits%'
$listViewSettingsLimits: '%module.listview.settings_limits%'
$listViewActionsLimits: '%module.listview.actions_limits%'

View file

@ -32,6 +32,7 @@ parameters:
listview_settings_limits: true
listview_actions_limits: true
listview_line_actions_limits: true
listview_url_query_filter_mapping: true
module_routing: true
recordview_actions_limits: true
subpanelview_actions_limits: true

View file

@ -0,0 +1,64 @@
parameters:
module.listview.url_query_filter_mapping:
'{field}_range_choice':
not_equal:
'range_{field}': 'target'
between:
'start_range_{field}': 'start'
'end_range_{field}': 'end'
'greater_than':
'range_{field}': 'target'
'less_than':
'range_{field}': 'target'
'last_7_days':
'{field}_range_choice': 'operator'
'next_7_days':
'{field}_range_choice': 'operator'
'last_30_days':
'{field}_range_choice': 'operator'
'next_30_days':
'{field}_range_choice': 'operator'
'last_month':
'{field}_range_choice': 'operator'
'this_month':
'{field}_range_choice': 'operator'
'next_month':
'{field}_range_choice': 'operator'
'last_year':
'{field}_range_choice': 'operator'
'this_year':
'{field}_range_choice': 'operator'
'next_year':
'{field}_range_choice': 'operator'
'{field}_filter_type':
equal:
'{field}_filter_value': 'target'
not_equal:
'{field}_filter_value': 'target'
between:
'{field}_filter_start': 'start'
'{field}_filter_end': 'end'
'greater_than':
'{field}_filter_start': 'target'
'less_than':
'{field}_filter_end': 'target'
'last_7_days':
'{field}_filter_type': 'operator'
'next_7_days':
'{field}_filter_type': 'operator'
'last_30_days':
'{field}_filter_type': 'operator'
'next_30_days':
'{field}_filter_type': 'operator'
'last_month':
'{field}_filter_type': 'operator'
'this_month':
'{field}_filter_type': 'operator'
'next_month':
'{field}_filter_type': 'operator'
'last_year':
'{field}_filter_type': 'operator'
'this_year':
'{field}_filter_type': 'operator'
'next_year':
'{field}_filter_type': 'operator'

View file

@ -26,7 +26,7 @@
import {ViewFieldDefinition} from './metadata.model';
import {WidgetMetadata} from './widget.metadata';
import {FieldDefinition} from '../record/field.model';
import {DisplayType,FieldDefinition} from '../record/field.model';
import {BulkActionsMap} from '../actions/bulk-action.model';
import {ChartTypesMap} from '../containers/chart/chart.model';
import {Action} from '../actions/action.model';
@ -65,6 +65,9 @@ export interface ColumnDefinition extends ViewFieldDefinition {
export interface SearchMetaField {
name?: string;
vardefBased?: boolean;
readonly?: boolean;
display?: DisplayType;
type?: string;
label?: string;
default?: boolean;

View file

@ -157,6 +157,8 @@ export interface AttributeDependency {
types: string[];
}
export type FieldSource = 'field' | 'attribute' | 'item' | 'groupField';
export interface Field {
type: string;
value?: string;
@ -164,6 +166,7 @@ export interface Field {
valueObject?: any;
valueObjectArray?: ObjectMap[];
name?: string;
vardefBased?: boolean;
label?: string;
labelKey?: string;
loading?: boolean;
@ -174,7 +177,7 @@ export interface Field {
readonly?: boolean;
display?: DisplayType;
defaultDisplay?: string;
source?: 'field' | 'attribute' | 'item';
source?: FieldSource;
valueSource?: 'value' | 'valueList' | 'valueObject' | 'criteria';
metadata?: FieldMetadata;
definition?: FieldDefinition;
@ -195,13 +198,14 @@ export interface Field {
export class BaseField implements Field {
type: string;
name?: string;
vardefBased?: boolean;
label?: string;
labelKey?: string;
dynamicLabelKey?: string;
readonly?: boolean;
display?: DisplayType;
defaultDisplay?: string;
source?: 'field' | 'attribute';
source?: FieldSource;
metadata?: FieldMetadata;
definition?: FieldDefinition;
criteria?: SearchCriteriaFieldFilter;

View file

@ -31,6 +31,7 @@ export interface SearchCriteriaFieldFilter {
fieldType?: string;
operator: string;
values?: string[];
target?: string;
start?: string;
end?: string;
valueObjectArray?: ObjectMap[];

View file

@ -24,6 +24,7 @@
* the words "Supercharged by SuiteCRM".
*/
import {isEmpty} from 'lodash-es';
import {Injectable} from '@angular/core';
import {StateStore} from '../../../../store/state';
import {
@ -33,7 +34,8 @@ import {
Field,
isVoid,
SearchMetaFieldMap,
ViewMode
ViewMode,
SearchCriteriaFieldFilter
} from 'common';
import {map, take, tap} from 'rxjs/operators';
import {BehaviorSubject, combineLatestWith, Observable, Subscription} from 'rxjs';
@ -219,15 +221,29 @@ export class ListFilterStore implements StateStore {
return;
}
Object.keys(savedFilter.criteriaFields).forEach(key => {
const field = savedFilter.criteriaFields[key];
Object.entries(savedFilter.criteriaFields).forEach(([key, field]) => {
const name = field.name || key;
if (name.includes('_only')) {
this.special.push(field);
} else {
this.fields.push(field);
return;
}
if (field.vardefBased) {
const filters = savedFilter?.criteria?.filters ?? {};
const fieldFilter = (
filters[key] ?? {}
) as SearchCriteriaFieldFilter;
if (
!isEmpty(fieldFilter?.operator)
&& field.display === 'none'
) {
field.display = 'default';
}
}
this.fields.push(field);
});
}
@ -246,9 +262,12 @@ export class ListFilterStore implements StateStore {
const fields = [];
this.fields.forEach(field => {
const name = field.name;
if (!this.searchFields[name]) {
if (field.display === 'none' || field.source === 'groupField') {
return;
}
if (!this.searchFields[name]) {
field.readonly = true;
}
fields.push(field);
});

View file

@ -290,6 +290,9 @@ export class SavedFilterRecordStore extends RecordStore {
const definition = {
name: fieldMeta.name,
label: fieldMeta.label,
vardefBased: fieldMeta?.vardefBased ?? false,
readonly: fieldMeta?.readonly ?? false,
display: fieldMeta?.display ?? '',
type,
fieldDefinition: {}
} as ViewFieldDefinition;

View file

@ -165,6 +165,11 @@ export class BaseEnumComponent extends BaseFieldComponent implements OnInit, OnD
this.selectedValues = [];
if (this.field.criteria){
this.initValueLabel();
return;
}
if (!this.field.value) {
this.initEnumDefault();
return;
@ -182,10 +187,15 @@ export class BaseEnumComponent extends BaseFieldComponent implements OnInit, OnD
return;
}
if (this.field.value) {
this.valueLabel = this.optionsMap[this.field.value];
this.initValueLabel();
}
protected initValueLabel () {
const fieldValue = this.field.value || this.field.criteria?.target || undefined;
if (fieldValue !== undefined) {
this.valueLabel = this.optionsMap[fieldValue];
this.selectedValues.push({
value: this.field.value,
value: fieldValue,
label: this.valueLabel
});
}

View file

@ -59,7 +59,7 @@ export class FieldComponent implements OnInit {
mode = 'edit';
}
if (mode === 'edit' && this.field.readonly) {
if (['edit', 'filter'].includes(mode) && this.field.readonly) {
mode = 'detail';
}

View file

@ -177,6 +177,7 @@ export class FieldBuilder {
field.type = viewField.type || definition.type;
field.name = viewField.name || definition.name || '';
field.vardefBased = viewField?.vardefBased ?? definition?.vardefBased ?? false;
field.readonly = isTrue(viewField.readonly) || isTrue(definition.readonly) || false;
field.display = (viewField.display || definition.display || 'default') as DisplayType;
field.defaultDisplay = field.display;

View file

@ -24,6 +24,7 @@
* the words "Supercharged by SuiteCRM".
*/
import {isEmpty} from 'lodash-es';
import {Field, FieldDefinition, Record, ViewFieldDefinition} from 'common';
import {LanguageStore} from '../../../store/language/language.store';
import {Injectable} from '@angular/core';
@ -121,6 +122,9 @@ export class FieldManager {
* @returns {object}Field
*/
public addFilterField(record: SavedFilter, viewField: ViewFieldDefinition, language: LanguageStore = null): Field {
if (viewField.vardefBased && !isEmpty(record.criteriaFields[viewField.name])) {
return record.criteriaFields[viewField.name];
}
const field = this.filterFieldBuilder.buildFilterField(record, viewField, language);

View file

@ -132,7 +132,8 @@ export class FilterFieldBuilder extends FieldBuilder {
* @returns {boolean} isInitialized
*/
public isCriteriaFieldInitialized(record: SavedFilter, fieldName: string): boolean {
return !!record.criteriaFields[fieldName];
const criteriaField = record.criteriaFields[fieldName];
return !!criteriaField && !criteriaField.vardefBased;
}
/**

View file

@ -86,6 +86,7 @@ export class GroupFieldBuilder extends FieldBuilder {
};
const groupField = buildFieldFunction(record, groupViewField, language);
groupField.source = 'groupField';
addRecordFunction(record, fieldDefinition.name, groupField);
});
}

View file

@ -0,0 +1,571 @@
/**
* SuiteCRM is a customer relationship management program developed by SalesAgility Ltd.
* Copyright (C) 2024 SalesAgility Ltd.
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License version 3 as published by the
* Free Software Foundation with the addition of the following permission added
* to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
* IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE
* WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* In accordance with Section 7(b) of the GNU Affero General Public License
* version 3, these Appropriate Legal Notices must retain the display of the
* "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably
* feasible for technical reasons, the Appropriate Legal Notices must display
* the words "Supercharged by SuiteCRM".
*/
import { isArray, isEmpty } from 'lodash-es';
import { DateTime } from 'luxon';
import {
FieldDefinitionMap,
isEmptyString,
SearchCriteria,
SearchCriteriaFieldFilter
} from 'common';
import { Injectable } from '@angular/core';
import { Params } from '@angular/router';
import { SavedFilter } from '../../../store/saved-filters/saved-filter.model';
import { MetadataStore } from '../../../store/metadata/metadata.store.service';
import { SystemConfigStore } from '../../../store/system-config/system-config.store';
import { DataTypeFormatter } from '../../../services/formatters/data-type.formatter.service';
type GenericMap<T> = { [key: string]: T };
type NestedGenericMap<T> = GenericMap<GenericMap<T>>;
type DoubleNestedGenericMap<T> = GenericMap<NestedGenericMap<T>>;
const MONTH_YEAR_REGEX = new RegExp('^(\\d{4})-(0[1-9]|1[0-2])$');
const MONTH_REGEX = new RegExp('^(\\d{4})$');
@Injectable({ providedIn: 'root' })
export class ListViewUrlQueryService {
/**
* Array of allowed properties to be set to the searchCriteriaFieldFilter from url_query_filter_mapping
*/
private allowedProperties = [
'operator',
'target',
'values',
'start',
'end'
];
/**
* An array containing properties that can be converted into dbFormat.
*/
private convertableProperties = [
'target',
'values',
'start',
'end'
];
constructor (
protected systemConfig: SystemConfigStore,
protected metadataStore: MetadataStore,
protected dataTypeFormatter: DataTypeFormatter
) {
}
/**
* Builds a URL query-based filter.
*
* @param {string} module - The module name.
* @param {SavedFilter} defaultFilter - The default filter.
* @param {Params} rawQueryParams - The raw query parameters.
* @returns {SavedFilter|null} - The built URL query-based filter, or null if no filter criteria are found.
*/
public buildUrlQueryBasedFilter (
module: string,
defaultFilter: SavedFilter,
rawQueryParams: Params
): SavedFilter | null {
const filterFieldDefinitions = this.metadataStore.get().recordView.vardefs;
const queryParams = Object.entries(rawQueryParams)
.reduce((acc, [queryParamKey, queryParamVal]) => {
const [cleanQueryParamKey, cleanQueryParamVal] = this.cleanQueryParam([
queryParamKey,
queryParamVal]);
acc[cleanQueryParamKey] = cleanQueryParamVal;
return acc;
}, {} as Params);
const filterCriteria: SearchCriteria = this.getQueryFilterCriteria(
filterFieldDefinitions,
module,
queryParams
);
if (isEmpty(filterCriteria.filters)) {
return null;
}
return {
key: 'default',
searchModule: module,
module: 'saved-search',
criteria: filterCriteria
} as SavedFilter;
}
/**
* Generates the query filter criteria based on the provided field definitions map, module, and query parameters.
*
* @param {FieldDefinitionMap} fieldDefinitionMap - The field definition map.
* @param {string} module - The module name.
* @param {Params} queryParams - The query parameters.
* @returns {SearchCriteria} - The generated search criteria.
* @protected
*/
protected getQueryFilterCriteria (
fieldDefinitionMap: FieldDefinitionMap,
module: string,
queryParams: Params
): SearchCriteria {
const criteria: SearchCriteria = {
name: 'default',
filters: {}
} as SearchCriteria;
const queryParamsKeys = Object.keys(queryParams);
const fieldDefinitions = Object.values(fieldDefinitionMap)
.filter(({ name }) => queryParamsKeys.some(qPKey => qPKey.includes(name)));
const listviewUrlQueryFilterMapping = this.systemConfig.getConfigValue(
'listview_url_query_filter_mapping'
) as DoubleNestedGenericMap<string>;
const listviewUrlQueryFilterMappingEntries = Object.entries(listviewUrlQueryFilterMapping);
listviewUrlQueryFilterMappingEntries.push(['', {}]);
let searchType;
switch (queryParams['searchFormTab']) {
case 'basic_search':
searchType = 'basic';
break;
case 'advanced_search':
searchType = 'advanced';
break;
default:
searchType = 'advanced';
}
for (const fieldDefinition of fieldDefinitions) {
const fieldFilterName = fieldDefinition.name;
const fieldFilterKeys = [
fieldFilterName,
`${fieldFilterName}_${searchType}`
];
for (const [queryFilterOperatorKeyTemplate, queryFilterOperatorParamsMap] of listviewUrlQueryFilterMappingEntries) {
if (!isEmpty(criteria.filters[fieldFilterName])) {
break;
}
for (const fieldFilterKey of fieldFilterKeys) {
if (!isEmpty(criteria.filters[fieldFilterName])) {
break;
}
const searchCriteriaFieldFilter = this.buildSearchCriteriaFieldFilter(
fieldFilterName,
fieldDefinition.type,
queryParams,
fieldFilterKey,
queryFilterOperatorKeyTemplate,
queryFilterOperatorParamsMap
);
if (isEmpty(searchCriteriaFieldFilter)) {
continue;
}
try {
this.convertableProperties.forEach((convertableProperty) => {
if (!searchCriteriaFieldFilter[convertableProperty]) {
return;
}
let internalFormatValue;
if (isArray(searchCriteriaFieldFilter[convertableProperty])) {
internalFormatValue = searchCriteriaFieldFilter[convertableProperty].map(
prop => this.toInternalFormat(
fieldDefinition.type,
prop
));
} else {
internalFormatValue = this.toInternalFormat(
fieldDefinition.type,
searchCriteriaFieldFilter[convertableProperty]
);
}
searchCriteriaFieldFilter[convertableProperty] = internalFormatValue;
});
} catch (e) {
continue;
}
criteria.filters[fieldFilterName] = searchCriteriaFieldFilter;
}
}
}
return criteria;
}
/**
* Builds a search criteria field filter object based on the provided parameters.
*
* @param {string} fieldFilterName - The name of the field filter.
* @param {string} fieldFilterFieldType - The type of the field filter.
* @param {Params} queryParams - The query parameters.
* @param {string} fieldFilterKey - The key of the field filter in the query parameters.
* @param {string} queryFilterOperatorKeyTemplate - The template for the query filter operator key.
* @param {NestedGenericMap<string>} queryFilterOperatorParamsMap - The map of query filter operator keys to their respective parameter maps.
* @returns {SearchCriteriaFieldFilter | null} The built search criteria field filter object.
* @protected
*/
protected buildSearchCriteriaFieldFilter (
fieldFilterName: string,
fieldFilterFieldType: string,
queryParams: Params,
fieldFilterKey: string,
queryFilterOperatorKeyTemplate: string,
queryFilterOperatorParamsMap: NestedGenericMap<string>
): SearchCriteriaFieldFilter | null {
const searchCriteriaFieldFilter = {
field: fieldFilterName,
fieldType: fieldFilterFieldType,
operator: '=',
values: []
} as SearchCriteriaFieldFilter;
if (isEmpty(queryFilterOperatorKeyTemplate) || isEmpty(queryFilterOperatorParamsMap)) {
const fieldFilterValue = this.getQueryParamValue(
fieldFilterKey,
fieldFilterKey,
queryParams
);
if (isEmpty(fieldFilterValue) && !isEmptyString(fieldFilterValue)) {
return null;
}
const values = isArray(fieldFilterValue)
? fieldFilterValue
: [fieldFilterValue];
searchCriteriaFieldFilter.values = values;
searchCriteriaFieldFilter.target = values[0];
return this.checkDateSpecialsOrReturn(
searchCriteriaFieldFilter,
searchCriteriaFieldFilter.target
);
}
const queryFilterOperatorKey = this.getQueryParamValue(
queryFilterOperatorKeyTemplate,
fieldFilterKey,
queryParams,
{ forceSingleString: true }
) as string;
const queryFilterOperatorParams = (
queryFilterOperatorParamsMap[queryFilterOperatorKey] ??
Object
.values(queryFilterOperatorParamsMap)
.reduce((prev, curr) => (
{ ...prev, ...curr }
), {})
?? {}
) as GenericMap<string>;
if (isEmpty(queryFilterOperatorParams)) {
return null;
}
let returnEmpty = true;
searchCriteriaFieldFilter.operator = queryFilterOperatorKey;
Object.entries(queryFilterOperatorParams)
.filter(([_, searchCriteriaPropertyKey]) => (
typeof searchCriteriaPropertyKey === 'string'
&& this.allowedProperties.includes(searchCriteriaPropertyKey)
))
.forEach(([searchCriteriaPropertyValueTemplate, searchCriteriaPropertyKey]) => {
const rawSearchCriteriaPropertyValue = this.getQueryParamValue(
searchCriteriaPropertyValueTemplate,
fieldFilterKey,
queryParams
);
if (isEmpty(rawSearchCriteriaPropertyValue)) {
return;
}
returnEmpty = false;
let searchCriteriaPropertyValue = rawSearchCriteriaPropertyValue;
if (searchCriteriaPropertyKey === 'values') {
if (!isArray(searchCriteriaPropertyValue)) {
searchCriteriaPropertyValue = [searchCriteriaPropertyValue];
}
searchCriteriaFieldFilter['target'] = searchCriteriaPropertyValue[0];
} else if (searchCriteriaPropertyKey === 'target') {
if (isArray(searchCriteriaPropertyValue)) {
searchCriteriaPropertyValue = searchCriteriaPropertyValue[0];
}
searchCriteriaFieldFilter['values'] = [searchCriteriaPropertyValue] as string[];
}
searchCriteriaFieldFilter[searchCriteriaPropertyKey] = searchCriteriaPropertyValue;
if (!isArray(searchCriteriaPropertyValue)) {
this.checkDateSpecialsOrReturn(
searchCriteriaFieldFilter,
searchCriteriaPropertyValue,
{
operator: queryFilterOperatorKey,
key: searchCriteriaPropertyKey
}
);
}
});
return !returnEmpty ? this.checkForMissingOperator(searchCriteriaFieldFilter) : null;
}
/**
* Retrieves the value of a query parameter based on the provided queryParamKeyTemplate,
* fieldFilterKey, and queryParams.
*
* @param {string} queryParamKeyTemplate - The template for the query parameter key, with "{field}" as a placeholder for fieldFilterKey.
* @param {string} fieldFilterKey - The field filter key used to replace the "{field}" placeholder in queryParamKeyTemplate.
* @param {Params} queryParams - The object containing the query parameters.
* @param {object} options - Optional parameters to customize the behavior of the method.
* @param {boolean} options.forceSingleString - Flag indicating whether the result should always be a single string value.
* @returns {string|string[]} - The value of the query parameter. If forceSingleString is false, it will be either a string or an array of strings.
* @protected
*/
protected getQueryParamValue (
queryParamKeyTemplate: string,
fieldFilterKey: string,
queryParams: Params,
{ forceSingleString = false } = {}
): string | string[] | null {
const queryParamKey = queryParamKeyTemplate.replace(
'{field}',
fieldFilterKey
) ?? '';
let queryParamValue = queryParams[queryParamKey];
if (!queryParamValue) {
return null;
}
if (isArray(queryParamValue)) {
queryParamValue = queryParamValue.map(this.transform);
} else {
queryParamValue = this.transform(queryParamValue);
}
if (forceSingleString && isArray(queryParamValue)) {
return queryParamValue[0] ?? '';
}
return queryParamValue;
}
/**
* Cleans the query parameter key by removing the '[]' brackets if present.
*
* @returns {string} - The cleaned query parameter key.
* @protected
* @param queryParam
*/
protected cleanQueryParam (queryParam: [string, string | string[]]): [string, string | string[]] {
let [queryParamKey, queryParamVal] = queryParam;
const queryParamKeyReversed = queryParamKey.split('').reverse().join('');
if (queryParamKeyReversed.indexOf('][') === 0 && typeof queryParamVal === 'string') {
queryParamKey = queryParamKey.replace('[]', '');
queryParamVal = queryParamVal.split(',');
}
return [queryParamKey, queryParamVal];
}
/**
* Checks if given fieldFilterValue matches MONTH_YEAR_REGEX or yearRegex and returns
* overridesSearchCriteriaFieldFilter if true, else returns searchCriteriaFieldFilter.
*
* @param {SearchCriteriaFieldFilter} searchCriteriaFieldFilter - The search criteria field filter.
* @param {string} fieldFilterValue - The field filter value.
* @param {Object} options - The options object.
* @param {string} [options.operator='='] - The range option.
* @param {string} [options.key='target'] - The key option.
* @returns {SearchCriteriaFieldFilter} - The updated search criteria field filter.
* @protected
*/
protected checkDateSpecialsOrReturn (
searchCriteriaFieldFilter: SearchCriteriaFieldFilter,
fieldFilterValue: string,
{ operator = '=', key = 'target' }: { operator?: string, key?: string } = {}
): SearchCriteriaFieldFilter {
if (fieldFilterValue.match(MONTH_YEAR_REGEX)) {
return this.overridesSearchCriteriaFieldFilter(
searchCriteriaFieldFilter,
fieldFilterValue,
{ type: 'month', operator, key }
);
}
if (fieldFilterValue.match(MONTH_REGEX)) {
return this.overridesSearchCriteriaFieldFilter(
searchCriteriaFieldFilter,
fieldFilterValue,
{ type: 'year', operator, key }
);
}
return searchCriteriaFieldFilter;
}
/**
* Overrides the search criteria field filter based on the provided parameters.
*
* @param {SearchCriteriaFieldFilter} searchCriteriaFieldFilter - The original search criteria field filter.
* @param {string} fieldFilterValue - The value of the field filter.
* @param {Object} options - The options for overriding the field filter.
* @param {string} options.type - The type of the field filter.
* @param {string} [options.operator='equal'] - The operator for the field filter.
* @param {string} [options.key='target'] - The key for the field filter.
* @protected
* @returns {SearchCriteriaFieldFilter} - The overridden search criteria field filter.
*/
protected overridesSearchCriteriaFieldFilter (
searchCriteriaFieldFilter: SearchCriteriaFieldFilter,
fieldFilterValue: string,
{ type = '', operator = 'equal', key = 'target' }: {
type: string,
operator?: string,
key?: string
}
): SearchCriteriaFieldFilter {
let plusObject;
let fmt;
switch (type) {
case 'year':
plusObject = { year: 1 };
fmt = 'yyyy';
break;
case 'month':
plusObject = { month: 1 };
fmt = 'yyyy-MM';
break;
default:
return searchCriteriaFieldFilter;
}
const start = DateTime.fromFormat(fieldFilterValue, fmt);
const end = start.plus(plusObject).minus({ day: 1 });
if (key !== 'target') {
switch (key) {
case 'start':
searchCriteriaFieldFilter.start = start.toFormat('yyyy-MM-dd');
break;
case 'end':
searchCriteriaFieldFilter.end = end.toFormat('yyyy-MM-dd');
break;
}
return searchCriteriaFieldFilter;
}
searchCriteriaFieldFilter.operator = operator;
switch (operator) {
case 'greater_than':
case 'greater_than_equals':
searchCriteriaFieldFilter.start = start.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.target = searchCriteriaFieldFilter.start;
searchCriteriaFieldFilter.values = [searchCriteriaFieldFilter.target];
break;
case 'less_than':
case 'less_than_equals':
searchCriteriaFieldFilter.end = end.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.target = searchCriteriaFieldFilter.end;
searchCriteriaFieldFilter.values = [searchCriteriaFieldFilter.target];
break;
case 'not_equal':
searchCriteriaFieldFilter.start = start.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.end = end.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.target = fieldFilterValue;
searchCriteriaFieldFilter.values = [fieldFilterValue];
break;
case 'equal':
case 'between':
default:
searchCriteriaFieldFilter.operator = 'between';
searchCriteriaFieldFilter.start = start.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.end = end.toFormat('yyyy-MM-dd');
searchCriteriaFieldFilter.target = '';
searchCriteriaFieldFilter.values = [];
break;
}
return searchCriteriaFieldFilter;
}
/**
* Converts the given value to the internal format based on the specified type.
*
* @param {string} type - The type of value to convert to.
* @param {string} value - The value to convert.
* @return {string} - The converted value in the internal format.
* @protected
*/
protected toInternalFormat (type: string, value: string): string {
if (value.match(MONTH_REGEX) || value.match(MONTH_YEAR_REGEX)) {
return value;
}
return this.dataTypeFormatter.toInternalFormat(type, value);
};
/**
* Transforms the given value from url to a value understandable by backend.
*
* @param {any} value - The value to be transformed.
* @protected
* @return {string} The transformed value.
*/
protected transform (value: any): string {
switch (value) {
case '':
return '__SuiteCRMEmptyString__';
default:
return value;
}
}
protected checkForMissingOperator (searchCriteriaFieldFilter: SearchCriteriaFieldFilter): SearchCriteriaFieldFilter {
if (
!isEmpty(searchCriteriaFieldFilter.start)
&& !isEmpty(searchCriteriaFieldFilter.end)
) {
searchCriteriaFieldFilter.operator = 'between';
}
return searchCriteriaFieldFilter;
}
}

View file

@ -24,6 +24,7 @@
* the words "Supercharged by SuiteCRM".
*/
import { isArray, isEmpty, union } from 'lodash-es';
import {
Action,
ColumnDefinition,
@ -37,11 +38,13 @@ import {
SelectionStatus,
SortDirection,
SortingSelection,
ViewContext
ViewContext,
isTrue
} from 'common';
import {BehaviorSubject, combineLatestWith, Observable, Subscription} from 'rxjs';
import {distinctUntilChanged, map, take, tap} from 'rxjs/operators';
import {Injectable} from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import {NavigationStore} from '../../../../store/navigation/navigation.store';
import {RecordList, RecordListStore} from '../../../../store/record-list/record-list.store';
import {Metadata, MetadataStore} from '../../../../store/metadata/metadata.store.service';
@ -61,7 +64,7 @@ import {FilterListStoreFactory} from '../../../../store/saved-filters/filter-lis
import {ConfirmationModalService} from '../../../../services/modals/confirmation-modal.service';
import {RecordPanelMetadata} from '../../../../containers/record-panel/store/record-panel/record-panel.store.model';
import {UserPreferenceStore} from '../../../../store/user-preference/user-preference.store';
import {isArray, union} from 'lodash-es';
import {ListViewUrlQueryService} from '../../services/list-view-url-query.service';
export interface ListViewData {
records: Record[];
@ -173,6 +176,8 @@ export class ListViewStore extends ViewStore implements StateStore {
protected filterListStoreFactory: FilterListStoreFactory,
protected confirmation: ConfirmationModalService,
protected preferences: UserPreferenceStore,
protected route: ActivatedRoute,
protected listViewUrlQueryService: ListViewUrlQueryService
) {
super(appStateStore, languageStore, navigationStore, moduleNavigation, metadataStore);
@ -356,8 +361,19 @@ export class ListViewStore extends ViewStore implements StateStore {
sortOrder: this?.metadata?.listView?.sortOrder ?? 'NONE' as SortDirection
} as SortingSelection;
this.loadCurrentFilter(module);
this.loadCurrentSort(module);
const queryParams = this.route?.snapshot?.queryParams ?? {};
let filterType = '';
if (isTrue(queryParams['query'])) {
filterType = 'query';
}
switch (filterType) {
case 'query':
this.loadQueryFilter(module, queryParams);
break
default:
this.loadCurrentFilter(module);
this.loadCurrentSort(module);
}
this.loadCurrentDisplayedColumns();
return this.load();
@ -778,6 +794,41 @@ export class ListViewStore extends ViewStore implements StateStore {
this.setFilters(activeFiltersPref, false, currentSort);
}
/**
* Load current filter
* @param module
* @param queryParams
* @protected
*/
protected loadQueryFilter (
module:string,
queryParams: Params
): void {
const orderBy: string = queryParams['orderBy'] ?? '';
const sortOrder: string = queryParams['sortOrder'] ?? '';
const direction = this.recordList.mapSortOrder(sortOrder);
const filter = this.listViewUrlQueryService.buildUrlQueryBasedFilter(
module,
this.internalState.activeFilters.default,
queryParams
);
if (isEmpty(filter)){
return;
}
const filters = { 'default': filter };
this.updateState({
...this.internalState,
activeFilters: deepClone(filters),
openFilter: deepClone(filter)
});
this.recordList.updateSorting(orderBy, direction, false);
this.recordList.updateSearchCriteria(filter.criteria, false);
}
/**
* Load current sorting
* @param module

View file

@ -130,6 +130,7 @@ class SystemConfigHandler extends LegacyHandler implements SystemConfigProviderI
array $recordViewActionLimits,
array $subpanelViewActionLimits,
array $listViewLineActionsLimits,
array $listViewUrlQueryFilterMapping,
array $uiConfigs,
array $notificationsConfigs,
array $notificationsReloadActions,
@ -161,6 +162,7 @@ class SystemConfigHandler extends LegacyHandler implements SystemConfigProviderI
$this->injectedSystemConfigs['recordview_actions_limits'] = $recordViewActionLimits;
$this->injectedSystemConfigs['subpanelview_actions_limits'] = $subpanelViewActionLimits;
$this->injectedSystemConfigs['listview_line_actions_limits'] = $listViewLineActionsLimits;
$this->injectedSystemConfigs['listview_url_query_filter_mapping'] = $listViewUrlQueryFilterMapping;
$this->injectedSystemConfigs['ui'] = $uiConfigs ?? [];
$this->injectedSystemConfigs['ui']['notifications'] = $notificationsConfigs ?? [];
$this->injectedSystemConfigs['ui']['notifications_reload_actions'] = $notificationsReloadActions ?? [];

View file

@ -338,8 +338,8 @@ class ViewDefinitionsHandler extends LegacyHandler implements ViewDefinitionsPro
unset($definition['templateMeta']);
}
$this->mergeSearchInfo($module, $definition, $searchDefs, 'basic_search');
$this->mergeSearchInfo($module, $definition, $searchDefs, 'advanced_search');
$this->mergeSearchInfo($module, $definition, $fieldDefinition, $searchDefs, 'basic_search');
$this->mergeSearchInfo($module, $definition, $fieldDefinition, $searchDefs, 'advanced_search');
$this->mergeFieldDefinition($definition, $fieldDefinition, 'basic_search');
$this->mergeFieldDefinition($definition, $fieldDefinition, 'advanced_search');
@ -359,15 +359,24 @@ class ViewDefinitionsHandler extends LegacyHandler implements ViewDefinitionsPro
protected function mergeFieldDefinition(array &$definition, FieldDefinition $fieldDefinition, string $type): void
{
$vardefs = $fieldDefinition->getVardef();
if (isset($definition['layout'][$type])) {
foreach ($definition['layout'][$type] as $key => $field) {
$fieldName = $this->getFieldName($key, $field);
if (!isset($definition['layout'][$type])) {
return;
}
foreach ($definition['layout'][$type] as $key => $field) {
$fieldName = $this->getFieldName($key, $field);
if (!empty($vardefs[$fieldName])) {
$merged = $this->addFieldDefinition($vardefs, $fieldName, $field);
$aliasKey = $merged['name'] ?? $key;
$definition['layout'][$type][$aliasKey] = $merged;
}
if (empty($vardefs[$fieldName])) {
continue;
}
$merged = $this->addFieldDefinition($vardefs, $fieldName, $field);
$merged = $this->injectEmptyOption($merged);
$aliasKey = $merged['name'] ?? $key;
$definition['layout'][$type][$aliasKey] = $merged;
if (empty($definition['layout'][$type][$aliasKey]['vardefBased'])) {
$definition['layout'][$type][$aliasKey]['vardefBased'] = false;
$definition['layout'][$type][$aliasKey]['readonly'] = false;
}
}
}
@ -407,17 +416,39 @@ class ViewDefinitionsHandler extends LegacyHandler implements ViewDefinitionsPro
* @param array $searchDefs
* @param string $type
*/
protected function mergeSearchInfo(string $module, array &$definition, array $searchDefs, string $type): void
protected function mergeSearchInfo(string $module, array &$definition, FieldDefinition $fieldDefinition, array $searchDefs, string $type): void
{
if (isset($definition['layout'][$type])) {
foreach ($definition['layout'][$type] as $key => $field) {
$name = $field['name'] ?? '';
if (!isset($definition['layout'][$type])) {
return;
}
if ($this->useRangeSearch($module, $searchDefs, $name)) {
$definition['layout'][$type][$key]['enable_range_search'] = true;
}
foreach ($definition['layout'][$type] as $key => $field) {
$name = $field['name'] ?? '';
if ($this->useRangeSearch($module, $searchDefs, $name)) {
$definition['layout'][$type][$key]['enable_range_search'] = true;
}
}
$layoutKeys = array_keys($definition['layout'][$type]);
$layoutValues = array_values($definition['layout'][$type]);
$vardefs = $fieldDefinition->getVardef();
foreach ($vardefs as $fieldVardefKey => $fieldVardefDefinition) {
if (
in_array($fieldVardefKey, $layoutKeys)
|| in_array($fieldVardefKey, $layoutValues)
) {
continue;
}
$definition['layout'][$type][$fieldVardefKey] = [
'name' => $fieldVardefKey,
'vardefBased' => true,
'display' => 'none',
'readonly' => true,
];
}
}
/**
@ -519,4 +550,29 @@ class ViewDefinitionsHandler extends LegacyHandler implements ViewDefinitionsPro
return $field;
}
/**
* Injects an empty option into the field options if it's not already present.
*
* @param array $merged The merged array containing field definition and metadata
* @return array The updated merged array with the empty option injected
*/
protected function injectEmptyOption(array $merged): array
{
if (empty($merged['fieldDefinition']['options'])) {
return $merged;
}
$metadata = $merged['fieldDefinition']['metadata'] ?? [];
$extraOptions = $metadata['extraOptions'] ?? [];
$extraOptions[] = [
'value' => '__SuiteCRMEmptyString__',
'labelKey' => 'LBL_EMPTY',
];
$metadata['extraOptions'] = $extraOptions;
$merged['fieldDefinition']['metadata'] = $metadata;
return $merged;
}
}

View file

@ -50,6 +50,21 @@ class DefaultFilterMapper implements FilterMapperInterface
}
$legacyValue = $values;
$mapEmptyString = false;
foreach ($legacyValue as $legacyValueKey => $legacyValueValue){
switch ($legacyValueValue) {
case "__SuiteCRMEmptyString__":
$mapEmptyString = true;
$legacyValue[$legacyValueKey] = '';
break;
}
}
if ($mapEmptyString){
return $legacyValue;
}
if (count($values) === 1) {
$legacyValue = $values[0];
}