Browse Source

Merge pull request #15286 from thingsboard/feature/dashboard-or-conditions

OR conditions support for dashboard filters
pull/15533/head
Viacheslav Klimov 5 months ago
committed by GitHub
parent
commit
d83216243a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  2. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
  4. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  5. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java
  7. 4
      application/src/main/resources/thingsboard.yml
  8. 2
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  9. 527
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  10. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  11. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
  12. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
  13. 2
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
  14. 12
      common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
  15. 4
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
  16. 7
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
  17. 5
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
  18. 2
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
  19. 13
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
  20. 6
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
  21. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
  22. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
  23. 2
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
  24. 84
      common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
  25. 16
      dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java
  26. 103
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  27. 161
      dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java
  28. 163
      edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java
  29. 3
      ui-ngx/src/app/core/api/alarm-data-subscription.ts
  30. 1
      ui-ngx/src/app/core/api/alarm-data.service.ts
  31. 10
      ui-ngx/src/app/core/api/alias-controller.ts
  32. 10
      ui-ngx/src/app/core/api/entity-data-subscription.ts
  33. 1
      ui-ngx/src/app/core/api/entity-data.service.ts
  34. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  35. 3
      ui-ngx/src/app/core/auth/auth.reducer.ts
  36. 23
      ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html
  37. 77
      ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html
  38. 28
      ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss
  39. 87
      ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts
  40. 112
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html
  41. 42
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss
  42. 121
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html
  43. 101
      ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html
  44. 142
      ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts
  45. 65
      ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts
  46. 111
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html
  47. 42
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss
  48. 11
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts
  49. 205
      ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html
  50. 148
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html
  51. 51
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss
  52. 126
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts
  53. 81
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html
  54. 6
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss
  55. 68
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts
  56. 2
      ui-ngx/src/app/shared/components/time/datetime.component.ts
  57. 18
      ui-ngx/src/app/shared/models/query/query.models.ts
  58. 2
      ui-ngx/src/app/shared/models/widget.models.ts
  59. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}") @Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes; private int defaultDebugDurationMinutes;
@Value("${sql.query.key-filters-or-conditions.enabled:true}")
private boolean keyFiltersOrConditionsEnabled;
@Autowired(required = false) @Autowired(required = false)
private BuildProperties buildProperties; private BuildProperties buildProperties;
@ -173,6 +176,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
.orElse(false)); .orElse(false));
systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled);
return systemParams; return systemParams;
} }

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;

2
application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java

@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;

4
application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java

@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME))); new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) {
@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
@Override @Override

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java

@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx<AlarmCountQu
private EntityDataQuery buildEntityDataQuery() { private EntityDataQuery buildEntityDataQuery() {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY))); new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
private void resetInvocationCounter() { private void resetInvocationCounter() {

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java

@ -363,7 +363,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx<AlarmDataQuery> {
entitiesSortOrder = sortOrder; entitiesSortOrder = sortOrder;
} }
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
} }
} }

4
application/src/main/resources/thingsboard.yml

@ -469,6 +469,10 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant # Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
query:
key-filters-or-conditions:
# Enable/disable OR conditions between key filters in entity data queries
enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}"
postgres: postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"

2
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;

527
application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java

@ -28,6 +28,7 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
@ -62,6 +63,8 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AliasEntityId;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.DynamicValueSourceType;
@ -89,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.dao.entity.BaseEntityService;
import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb; import org.thingsboard.server.edqs.util.EdqsRocksDb;
@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired @Autowired
private QueueStatsService queueStatsService; private QueueStatsService queueStatsService;
@Autowired
private BaseEntityService baseEntityService;
@MockitoBean @MockitoBean
private EdqsRocksDb edqsRocksDb; private EdqsRocksDb edqsRocksDb;
@ -1343,7 +1349,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard //assign dashboard
doPost("/api/customer/" + savedCustomer.getId().getId().toString() doPost("/api/customer/" + savedCustomer.getId().getId().toString()
+ "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
// check entity data query by customer // check entity data query by customer
User customerUser = new User(); User customerUser = new User();
@ -1729,4 +1735,523 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
} }
// --- OR conditions tests ---
private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
if (sharedAttributesPayload != null) {
doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
sharedAttributesPayload, String.class, status().isOk());
}
return device;
}
private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload);
doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload)
.andExpect(status().isOk());
return device;
}
private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception {
Alarm alarm = new Alarm();
alarm.setOriginator(originator);
alarm.setType(type);
alarm.setSeverity(severity);
return doPost("/api/alarm", alarm, Alarm.class);
}
private static DeviceTypeFilter deviceTypeFilter(String type) {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of(type));
filter.setDeviceNameFilter("");
return filter;
}
private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key,
NumericFilterPredicate.NumericOperation operation, double value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.NUMERIC);
NumericFilterPredicate predicate = new NumericFilterPredicate();
predicate.setValue(FilterPredicateValue.fromDouble(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter numericAttributeKeyFilter(String key,
NumericFilterPredicate.NumericOperation operation, double value) {
return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key,
StringFilterPredicate.StringOperation operation, String value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.STRING);
StringFilterPredicate predicate = new StringFilterPredicate();
predicate.setValue(FilterPredicateValue.fromString(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter stringAttributeKeyFilter(String key,
StringFilterPredicate.StringOperation operation, String value) {
return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) {
EntityDataSortOrder sortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC);
return new EntityDataPageLink(pageSize, page, textSearch, sortOrder);
}
private static List<EntityKey> nameEntityField() {
return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
}
private static List<String> extractNames(PageData<EntityData> result) {
return result.getData().stream()
.map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
.collect(Collectors.toList());
}
@Test
public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
String type = "orTestType";
createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception {
String type = "orDataType";
createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}");
createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}");
createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70));
// OR: deviceX matches status=active, deviceY matches humidity>70
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
}
@Test
public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception {
// Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in
// entityFields must not leak its value into EntityData.latest[ENTITY_FIELD].
String type = "orLeakGuardType";
Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}");
d1.setLabel("leak-label-A");
doPost("/api/device", d1, Device.class);
Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null);
d2.setLabel("leak-label-B");
doPost("/api/device", d2, Device.class);
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B"));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB");
for (EntityData entity : result.getData()) {
assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name");
}
}
@Test
public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
String type = "orSameKeyType";
createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}");
// Same key "temperature" with two different predicates exercises the OR-ungrouping path
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
}
@Test
public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception {
String type = "backCompatType";
createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Await attribute propagation via an OR query that should find 2 when propagated
EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2));
// Query without keyFiltersOperation (null) -- should behave as AND
EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters);
Long nullResult = countByQueryAndCheck(nullOpQuery, 0);
// Query with explicit AND -- should produce the same result
EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andOpQuery, 0);
Assert.assertEquals(nullResult, andResult);
}
@Test
public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}");
createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
AlarmDataPageLink pageLink = new AlarmDataPageLink();
pageLink.setPage(0);
pageLink.setPageSize(100);
pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime")));
List<EntityKey> alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
// OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms
AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2));
PageData<AlarmData> alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
List<String> alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList());
assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp");
// AND query: no device has temp both >50 AND <10 => 0 alarms
AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
findAlarmsByQueryAndCheck(andAlarmQuery, 0);
}
@Test
public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmCntType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}");
// 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL);
createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total
AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3));
// AND: no device matches both filters => 0
AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countAlarmsByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception {
// Exercises the entity field predicate relocation to middle-layer WHERE under OR
String type = "orMixedType";
createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}");
createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}");
List<KeyFilter> keyFilters = List.of(
stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrStringAttributes() throws Exception {
String type = "orStrType";
createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}");
createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}");
createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}");
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"),
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue"));
// OR: red and blue match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device is both red AND blue => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrSingleFilter() throws Exception {
String type = "orSingleType";
createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// Single filter with OR should behave identically to AND
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1));
Long orResult = countByQueryAndCheck(orQuery, 1);
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andQuery, 1);
Assert.assertEquals(orResult, andResult);
}
@Test
public void testCountEntitiesWithOrThreeFilters() throws Exception {
String type = "or3fType";
createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}");
createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80),
numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040));
// OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3));
// AND: no device matches all three => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrPagination() throws Exception {
// 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read.
String type = "orPageType";
int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match
for (int i = 0; i < temperatures.length; i++) {
createDeviceWithSharedAttributes(
String.format("OrPageDevice%02d", i + 1), type,
"{\"temperature\":" + temperatures[i] + "}");
}
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Page 1: pageSize=2, totalElements=4, data.size()=2
EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4));
PageData<EntityData> page1 = findByQueryAndCheck(orQuery1, 4);
Assert.assertEquals(2, page1.getData().size());
Assert.assertTrue(page1.hasNext());
// Page 2: remaining 2 of 4
EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
PageData<EntityData> page2 = findByQueryAndCheck(orQuery2, 4);
Assert.assertEquals(2, page2.getData().size());
Assert.assertFalse(page2.hasNext());
// All names across both pages should be from the 4 matching devices (01..04)
List<String> allNames = new ArrayList<>(extractNames(page1));
allNames.addAll(extractNames(page2));
assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05");
}
@Test
public void testCountEntitiesWithOrZeroMatches() throws Exception {
String type = "orZeroType";
createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}");
createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}");
// Await attribute propagation via a filter that actually matches both devices
EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)),
ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2));
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR with no matches: neither filter matches any device => count=0
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
countByQueryAndCheck(orQuery, 0);
}
@Test
public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception {
loginTenantAdmin();
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false);
try {
DeviceTypeFilter filter = deviceTypeFilter("default");
// POST a query with OR operation -- should be rejected with 400
EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR);
String errorMessage = getErrorMessage(
doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest())
);
assertThat(errorMessage).contains("OR conditions between key filters are disabled");
// POST a query without keyFiltersOperation (null/AND) -- should still succeed
EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList());
doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk());
// POST a query with explicit AND -- should also succeed
EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND);
doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk());
} finally {
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true);
}
}
@Test
public void testFindEntityDataWithOrAndTextSearch() throws Exception {
// 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time
String type = "orTextType";
createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR without textSearch: Alpha and Beta match => 2
EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
// OR with textSearch="Alpha": only Alpha matches both OR filter AND text search
EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha");
// OR with textSearch="Beta": only Beta matches both OR filter AND text search
EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta");
// OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0
EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
findByQueryAndCheck(orQueryGamma, 0);
}
@Test
public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception {
String type = "orTsType";
createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}");
createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}");
createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has ts temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception {
// Key-level ComplexFilterPredicate combined with query-level OR
String type = "orCplxType";
createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}");
createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}");
createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}");
// Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only
NumericFilterPredicate gt50 = new NumericFilterPredicate();
gt50.setValue(FilterPredicateValue.fromDouble(50));
gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
NumericFilterPredicate lt70 = new NumericFilterPredicate();
lt70.setValue(FilterPredicateValue.fromDouble(70));
lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS);
ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate();
complexTempPred.setOperation(ComplexOperation.AND);
complexTempPred.setPredicates(List.of(gt50, lt70));
KeyFilter tempComplexFilter = new KeyFilter();
tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC);
tempComplexFilter.setPredicate(complexTempPred);
// Key filter 2: humidity > 80 (simple predicate) — matches B only
List<KeyFilter> keyFilters = List.of(
tempComplexFilter,
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80));
// Query-level OR: A matches key filter 1, B matches key filter 2 => 2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// Query-level AND: no device matches both key filters => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
} }

1
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -45,4 +45,5 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF; long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings; TrendzSettings trendzSettings;
boolean allowKeyFiltersOrConditions;
} }

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java

@ -24,6 +24,7 @@ import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.EntityKeyValueType;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java

@ -22,6 +22,7 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List; import java.util.List;

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java

@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List; import java.util.List;

12
common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java

@ -49,4 +49,16 @@ public abstract class AbstractDataQuery<T extends EntityDataPageLink> extends En
this.latestValues = latestValues; this.latestValues = latestValues;
} }
public AbstractDataQuery(EntityFilter entityFilter,
T pageLink,
List<EntityKey> entityFields,
List<EntityKey> latestValues,
List<KeyFilter> keyFilters,
ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
this.pageLink = pageLink;
this.entityFields = entityFields;
this.latestValues = latestValues;
}
} }

4
common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java

@ -51,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery {
super(entityFilter, keyFilters); super(entityFilter, keyFilters);
} }
public AlarmCountQuery(EntityFilter entityFilter, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
}
} }

7
common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java

@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery<AlarmDataPageLink> {
this.alarmFields = alarmFields; this.alarmFields = alarmFields;
} }
public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> keyFilters, List<EntityKey> alarmFields, ComplexOperation keyFiltersOperation) {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
this.alarmFields = alarmFields;
}
@JsonIgnore @JsonIgnore
public AlarmDataQuery next() { public AlarmDataQuery next() {
return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields); return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation());
} }
} }

5
common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java

@ -34,9 +34,4 @@ public class ComplexFilterPredicate implements KeyFilterPredicate {
return FilterPredicateType.COMPLEX; return FilterPredicateType.COMPLEX;
} }
@Schema
public enum ComplexOperation {
AND,
OR
}
} }

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java → common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.thingsboard.server.common.data.alarm.rule.condition.expression; package org.thingsboard.server.common.data.query;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;

13
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java

@ -34,6 +34,9 @@ public class EntityCountQuery {
@Getter @Getter
protected List<KeyFilter> keyFilters; protected List<KeyFilter> keyFilters;
@Getter
private ComplexOperation keyFiltersOperation;
public EntityCountQuery() { public EntityCountQuery() {
} }
@ -45,4 +48,14 @@ public class EntityCountQuery {
this.entityFilter = entityFilter; this.entityFilter = entityFilter;
this.keyFilters = keyFilters; this.keyFilters = keyFilters;
} }
public EntityCountQuery(EntityFilter entityFilter, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
this.entityFilter = entityFilter;
this.keyFilters = keyFilters;
this.keyFiltersOperation = keyFiltersOperation;
}
public ComplexOperation getKeyFiltersOperationOrDefault() {
return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND;
}
} }

6
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java

@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery<EntityDataPageLink> {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters); super(entityFilter, pageLink, entityFields, latestValues, keyFilters);
} }
public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
}
@JsonIgnore @JsonIgnore
public EntityDataQuery next() { public EntityDataQuery next() {
return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters); return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation());
} }
} }

5
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java

@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query; package org.thingsboard.server.edqs.query;
import lombok.Builder; import lombok.Builder;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List; import java.util.List;
@ -23,8 +24,8 @@ import java.util.List;
public class EdqsCountQuery extends EdqsQuery { public class EdqsCountQuery extends EdqsQuery {
@Builder @Builder
EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List<EdqsFilter> keyFilters) { EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, hasKeyFilters, keyFilters); super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation);
} }
} }

5
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java

@ -19,6 +19,7 @@ import lombok.Builder;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityFilter;
import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyType;
@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery {
private final List<DataKey> latestValues; private final List<DataKey> latestValues;
@Builder @Builder
public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters, public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation,
int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection, int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection,
List<DataKey> entityFields, List<DataKey> latestValues) { List<DataKey> entityFields, List<DataKey> latestValues) {
super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters); super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation);
this.pageSize = pageSize; this.pageSize = pageSize;
this.page = page; this.page = page;
this.hasTextSearch = StringUtils.isNotBlank(textSearch); this.hasTextSearch = StringUtils.isNotBlank(textSearch);

2
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java

@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query; package org.thingsboard.server.edqs.query;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List; import java.util.List;
@ -26,5 +27,6 @@ public abstract class EdqsQuery {
private final EntityFilter entityFilter; private final EntityFilter entityFilter;
private final boolean hasKeyFilters; private final boolean hasKeyFilters;
private final List<EdqsFilter> keyFilters; private final List<EdqsFilter> keyFilters;
private final ComplexOperation keyFiltersOperation;
} }

84
common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint;
import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.permission.QueryContext;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@ -60,8 +61,8 @@ import java.util.stream.Stream;
import static org.apache.commons.lang3.StringUtils.containsIgnoreCase; import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
import static org.thingsboard.server.common.data.StringUtils.equalsAny; import static org.thingsboard.server.common.data.StringUtils.equalsAny;
import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes;
import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND; import static org.thingsboard.server.common.data.query.ComplexOperation.AND;
import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR; import static org.thingsboard.server.common.data.query.ComplexOperation.OR;
@Slf4j @Slf4j
public class RepositoryUtils { public class RepositoryUtils {
@ -139,6 +140,7 @@ public class RepositoryUtils {
} }
query.entityFilter(oldQuery.getEntityFilter()); query.entityFilter(oldQuery.getEntityFilter());
query.keyFilters(toKeyFilters(oldQuery.getKeyFilters())); query.keyFilters(toKeyFilters(oldQuery.getKeyFilters()));
query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault());
query.entityFields(toNewKeys(oldQuery.getEntityFields())); query.entityFields(toNewKeys(oldQuery.getEntityFields()));
query.latestValues(toNewKeys(oldQuery.getLatestValues())); query.latestValues(toNewKeys(oldQuery.getLatestValues()));
return query.build(); return query.build();
@ -149,6 +151,7 @@ public class RepositoryUtils {
.entityFilter(oldQuery.getEntityFilter()) .entityFilter(oldQuery.getEntityFilter())
.hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters())) .hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters()))
.keyFilters(toKeyFilters(oldQuery.getKeyFilters())) .keyFilters(toKeyFilters(oldQuery.getKeyFilters()))
.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault())
.build(); .build();
} }
@ -196,38 +199,57 @@ public class RepositoryUtils {
} }
public static boolean checkKeyFilters(EntityData entity, List<EdqsFilter> keyFilters) { public static boolean checkKeyFilters(EntityData entity, List<EdqsFilter> keyFilters) {
for (EdqsFilter keyFilter : keyFilters) { return checkKeyFilters(entity, keyFilters, ComplexOperation.AND);
EntityKeyValueType valueType = keyFilter.valueType(); }
if (valueType == null) {
valueType = switch (keyFilter.predicate().getType()) { public static boolean checkKeyFilters(EntityData entity, List<EdqsFilter> keyFilters, ComplexOperation operation) {
case STRING -> EntityKeyValueType.STRING; ComplexOperation op = operation != null ? operation : ComplexOperation.AND;
case NUMERIC -> EntityKeyValueType.NUMERIC; if (op == ComplexOperation.OR) {
case BOOLEAN -> EntityKeyValueType.BOOLEAN; for (EdqsFilter keyFilter : keyFilters) {
default -> throw new IllegalStateException(); if (evaluateSingleFilter(entity, keyFilter)) {
}; return true;
}
DataKey dataKey = keyFilter.key();
DataPoint dp = entity.getDataPoint(dataKey, null);
boolean checkResult = switch (valueType) {
case STRING -> {
String str = dp != null ? dp.valueToString() : null;
yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
(str != null && checkKeyFilter(str, keyFilter.predicate()));
}
case BOOLEAN -> {
Boolean booleanValue = dp != null ? dp.getBool() : null;
yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
} }
case DATE_TIME, NUMERIC -> { }
Double doubleValue = dp != null ? dp.getDouble() : null; // Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters
yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); // (which guards on isHasKeyFilters()), but kept defensive for any future direct caller.
return keyFilters.isEmpty();
} else {
for (EdqsFilter keyFilter : keyFilters) {
if (!evaluateSingleFilter(entity, keyFilter)) {
return false;
} }
};
if (!checkResult) {
return false;
} }
return true;
} }
return true; }
private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) {
EntityKeyValueType valueType = keyFilter.valueType();
if (valueType == null) {
valueType = switch (keyFilter.predicate().getType()) {
case STRING -> EntityKeyValueType.STRING;
case NUMERIC -> EntityKeyValueType.NUMERIC;
case BOOLEAN -> EntityKeyValueType.BOOLEAN;
default -> throw new IllegalStateException();
};
}
DataKey dataKey = keyFilter.key();
DataPoint dp = entity.getDataPoint(dataKey, null);
return switch (valueType) {
case STRING -> {
String str = dp != null ? dp.valueToString() : null;
yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
(str != null && checkKeyFilter(str, keyFilter.predicate()));
}
case BOOLEAN -> {
Boolean booleanValue = dp != null ? dp.getBool() : null;
yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
}
case DATE_TIME, NUMERIC -> {
Double doubleValue = dp != null ? dp.getDouble() : null;
yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate());
}
};
} }
public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) { public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) {
@ -376,7 +398,7 @@ public class RepositoryUtils {
if (entity == null || entity.getFields() == null) { if (entity == null || entity.getFields() == null) {
return false; // Entity was already removed or not arrived yet; return false; // Entity was already removed or not arrived yet;
} }
if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) { if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) {
return false; return false;
} }
if (query instanceof EdqsDataQuery dataQuery) { if (query instanceof EdqsDataQuery dataQuery) {

16
dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java

@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails; import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataPageLink;
@ -91,6 +93,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
private static final Set<EntityFilterType> EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( private static final Set<EntityFilterType> EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of(
EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY);
@Value("${sql.query.key-filters-or-conditions.enabled:true}")
private boolean keyFiltersOrConditionsEnabled;
@Autowired @Autowired
private EntityQueryDao entityQueryDao; private EntityQueryDao entityQueryDao;
@ -328,7 +333,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity)); return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity));
} }
private static void validateEntityCountQuery(EntityCountQuery query) { private void validateEntityCountQuery(EntityCountQuery query) {
if (query == null) { if (query == null) {
throw new IncorrectParameterException("Query must be specified."); throw new IncorrectParameterException("Query must be specified.");
} else if (query.getEntityFilter() == null) { } else if (query.getEntityFilter() == null) {
@ -342,9 +347,14 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
} else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) { } else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) {
validateEntityNameQuery((EntityNameFilter) query.getEntityFilter()); validateEntityNameQuery((EntityNameFilter) query.getEntityFilter());
} }
// Intentionally using the nullable getKeyFiltersOperation() (not getKeyFiltersOperationOrDefault()):
// a null value encodes "classic AND" and must pass this guard even when the OR feature flag is off.
if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) {
throw new IncorrectParameterException("OR conditions between key filters are disabled by the system administrator.");
}
} }
private static void validateEntityDataQuery(EntityDataQuery query) { private void validateEntityDataQuery(EntityDataQuery query) {
validateEntityCountQuery(query); validateEntityCountQuery(query);
validateEntityDataPageLink(query.getPageLink()); validateEntityDataPageLink(query.getPageLink());
} }
@ -409,7 +419,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }
EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters(), query.getKeyFiltersOperation());
return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery);
} }

103
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.permission.QueryContext;
import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.ApiUsageStateFilter;
import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.AssetTypeFilter; import org.thingsboard.server.common.data.query.AssetTypeFilter;
import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter;
import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter;
@ -54,6 +55,7 @@ import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -351,25 +353,56 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
} }
}); });
} else { } else {
List<EntityKeyMapping> mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
boolean isOr = operation == ComplexOperation.OR;
List<EntityKeyMapping> selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) List<EntityKeyMapping> mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query);
.collect(Collectors.toList());
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList()));
List<EntityKeyMapping> filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter) List<EntityKeyMapping> filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null)
.collect(Collectors.toList()); .collect(Collectors.toList());
// Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference.
// Mirror the ignore=true fix from findEntityDataByQuery so the inner subquery still emits the
// extra column but downstream response shape (benign for count) stays symmetric with the data path.
if (isOr) {
for (EntityKeyMapping m : entityFieldsFiltersMapping) {
if (!selectionMapping.contains(m)) {
m.setIgnore(true);
selectionMapping.add(m);
}
}
}
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList()); .collect(Collectors.toList());
// Under OR: entity field filters move to outer WHERE (not inner WHERE)
List<EntityKeyMapping> innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping;
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr);
// Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner
// Place at the middle layer (after JOINs) where alias table references are visible.
// Under AND: place at the outer result layer (existing behavior).
String aliasWhereQuery;
if (isOr) {
String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR);
if (!combinedFilterQuery.isEmpty()) {
latestJoinsCnt += " where (" + combinedFilterQuery + ")";
}
aliasWhereQuery = "";
} else {
aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, "");
}
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping);
String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, "");
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true);
String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType);
String entityTypeStr; String entityTypeStr;
if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) {
@ -419,13 +452,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityType entityType = resolveEntityType(query.getEntityFilter());
SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck)); SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck));
EntityDataPageLink pageLink = query.getPageLink(); EntityDataPageLink pageLink = query.getPageLink();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
boolean isOr = operation == ComplexOperation.OR;
List<EntityKeyMapping> mappings = EntityKeyMapping.prepareKeyMapping(entityType, query); List<EntityKeyMapping> mappings = EntityKeyMapping.prepareKeyMapping(entityType, query);
List<EntityKeyMapping> selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) List<EntityKeyMapping> selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList()); .collect(Collectors.toList()));
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest) List<EntityKeyMapping> latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -434,14 +467,52 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null)
.collect(Collectors.toList()); .collect(Collectors.toList());
// Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference.
// Mark force-added filter-only mappings as ignored so EntityDataAdapter does not expose
// them in EntityData.latest — keeps the response shape identical to AND.
if (isOr) {
for (EntityKeyMapping m : entityFieldsFiltersMapping) {
if (!selectionMapping.contains(m)) {
m.setIgnore(true);
selectionMapping.add(m);
}
}
}
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList()); .collect(Collectors.toList());
// Under OR: entity field filters move to outer WHERE (not inner WHERE)
List<EntityKeyMapping> innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping;
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr);
String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr);
// Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner
// Place at the middle layer (after JOINs) where alias table references are visible.
// Under AND: place at the outer result layer (existing behavior).
String aliasWhereQuery;
if (isOr) {
String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR);
String middleWhere = "";
if (!combinedFilterQuery.isEmpty()) {
middleWhere = " where (" + combinedFilterQuery + ")";
}
String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch());
if (!searchTextQuery.isEmpty()) {
middleWhere += (middleWhere.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") ";
}
latestJoinsCnt += middleWhere;
latestJoinsData += middleWhere;
aliasWhereQuery = "";
} else {
aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch());
}
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true);
String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false);
String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch());
String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType);
String entityTypeStr; String entityTypeStr;
if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) {

161
dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java

@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@ -283,16 +284,39 @@ public class EntityKeyMapping {
} }
public Stream<String> toQueries(SqlQueryContext ctx, EntityFilterType filterType) { public Stream<String> toQueries(SqlQueryContext ctx, EntityFilterType filterType) {
if (hasFilter()) { return toQueries(ctx, filterType, false);
String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias; }
return keyFilters.stream().map(keyFilter ->
this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType)); public Stream<String> toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) {
} else { if (!hasFilter()) {
return Stream.empty(); return Stream.empty();
} }
String keyAlias;
boolean useAliasDirectly = false;
if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) {
if (outerContext) {
// In the middle layer (OR relocation), entity field columns are exposed
// by their alias name from the inner subquery SELECT (e.g., "alias2" from
// "cast(e.name as varchar) as alias2"), so buildSimplePredicateQuery must
// use the alias directly as the field instead of appending entityKeyColumn.
keyAlias = alias;
useAliasDirectly = true;
} else {
keyAlias = "e";
}
} else {
keyAlias = alias;
}
final boolean aliasAsField = useAliasDirectly;
return keyFilters.stream().map(keyFilter ->
this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType, aliasAsField));
} }
public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) {
return toLatestJoin(ctx, entityFilter, entityType, false);
}
public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, boolean forceLeftJoin) {
String entityTypeStr; String entityTypeStr;
if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) { if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) {
entityTypeStr = "entities.entity_type"; entityTypeStr = "entities.entity_type";
@ -300,20 +324,23 @@ public class EntityKeyMapping {
entityTypeStr = "'" + entityType.name() + "'"; entityTypeStr = "'" + entityType.name() + "'";
} }
ctx.addStringParameter(getKeyId(), entityKey.getKey()); ctx.addStringParameter(getKeyId(), entityKey.getKey());
String filterQuery = toQueries(ctx, entityFilter.getType()) // Under OR (forceLeftJoin=true) the filter predicate is re-emitted by buildQuery at the middle
// layer with disjunction semantics. Inlining it in the ON clause here would duplicate the
// predicate with a different bound parameter name and double-filter the LEFT JOIN.
String filterQuery = forceLeftJoin ? "" : toQueries(ctx, entityFilter.getType())
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" and ")); .collect(Collectors.joining(" and "));
if (StringUtils.isNotEmpty(filterQuery)) { if (StringUtils.isNotEmpty(filterQuery)) {
filterQuery = " AND (" + filterQuery + ")"; filterQuery = " AND (" + filterQuery + ")";
} }
if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) { if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) {
String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s", return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s",
join, alias, alias, alias, alias, filterQuery); join, alias, alias, alias, alias, filterQuery);
} else { } else {
String query; String query;
if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) { if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) {
String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ", query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ",
join, alias, alias, alias, alias); join, alias, alias, alias, alias);
int scope; int scope;
@ -326,7 +353,7 @@ public class EntityKeyMapping {
} }
query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery); query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery);
} else { } else {
String join = (hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL";
query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " + query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " +
"ORDER BY %s.last_update_ts DESC limit 1) as %s ON true", "ORDER BY %s.last_update_ts DESC limit 1) as %s ON true",
join, alias, alias, alias, alias, filterQuery, alias, alias); join, alias, alias, alias, alias, filterQuery, alias, alias);
@ -351,27 +378,47 @@ public class EntityKeyMapping {
} }
public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List<EntityKeyMapping> latestMappings, boolean countQuery) { public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List<EntityKeyMapping> latestMappings, boolean countQuery) {
return buildLatestJoins(ctx, entityFilter, entityType, latestMappings, countQuery, false);
}
public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType,
List<EntityKeyMapping> latestMappings, boolean countQuery, boolean forceLeftJoin) {
return latestMappings.stream() return latestMappings.stream()
.filter(mapping -> !countQuery || mapping.hasFilter()) .filter(mapping -> !countQuery || mapping.hasFilter())
.map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType)) .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType, forceLeftJoin))
.collect(Collectors.joining(" ")); .collect(Collectors.joining(" "));
} }
public static String buildQuery(SqlQueryContext ctx, List<EntityKeyMapping> mappings, EntityFilterType filterType) { public static String buildQuery(SqlQueryContext ctx, List<EntityKeyMapping> mappings, EntityFilterType filterType) {
return buildQuery(ctx, mappings, filterType, ComplexOperation.AND);
}
public static String buildQuery(SqlQueryContext ctx, List<EntityKeyMapping> mappings,
EntityFilterType filterType, ComplexOperation operation) {
String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND ";
// Vacuously-true predicates (e.g. a ComplexFilterPredicate with zero nested predicates producing
// an empty string) are dropped here. Under AND this is safe — TRUE is the identity for AND — but
// under OR it silently narrows the disjunction. Callers must not emit meaningful empty predicates.
return mappings.stream() return mappings.stream()
.flatMap(mapping -> mapping.toQueries(ctx, filterType)) .flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR))
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" AND ")); .collect(Collectors.joining(joiner));
} }
public static List<EntityKeyMapping> prepareKeyMapping(EntityType entityType, EntityDataQuery query) { public static List<EntityKeyMapping> prepareKeyMapping(EntityType entityType, EntityDataQuery query) {
EntityFilterType entityFilterType = query.getEntityFilter().getType(); EntityFilterType entityFilterType = query.getEntityFilter().getType();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
List<EntityKey> entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList(); List<EntityKey> entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList();
List<EntityKey> latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList(); List<EntityKey> latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList();
Map<EntityKey, List<KeyFilter>> filters = Map<EntityKey, List<KeyFilter>> filters;
query.getKeyFilters() != null ? if (operation == ComplexOperation.OR) {
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); // Under OR, don't group same-key filters; handle individually below
filters = Collections.emptyMap();
} else {
filters = query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
}
EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder();
EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null; EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null;
int index = 2; int index = 2;
@ -428,14 +475,37 @@ public class EntityKeyMapping {
for (EntityKeyMapping mapping : mappings) { for (EntityKeyMapping mapping : mappings) {
mapping.setIndex(index); mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index)); mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.remove(mapping.entityKey)); if (operation != ComplexOperation.OR) {
// Under AND: assign grouped filters to matching selection mappings
mapping.setKeyFilters(filters.remove(mapping.entityKey));
}
if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) { if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) {
index++; index++;
} else { } else {
index += 2; index += 2;
} }
} }
if (!filters.isEmpty()) { if (operation == ComplexOperation.OR && query.getKeyFilters() != null) {
// Under OR: each KeyFilter gets its own EntityKeyMapping (no same-key grouping)
for (KeyFilter keyFilter : query.getKeyFilters()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(Collections.singletonList(keyFilter));
EntityKey filterKey = keyFilter.getKey();
mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterKey);
mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping);
if (filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)) {
index += 1;
} else {
index += 2;
}
}
} else if (!filters.isEmpty()) {
// Under AND: create mappings for filter-only keys not in selection
for (EntityKey filterField : filters.keySet()) { for (EntityKey filterField : filters.keySet()) {
EntityKeyMapping mapping = new EntityKeyMapping(); EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index); mapping.setIndex(index);
@ -464,25 +534,45 @@ public class EntityKeyMapping {
public static List<EntityKeyMapping> prepareEntityCountKeyMapping(EntityCountQuery query) { public static List<EntityKeyMapping> prepareEntityCountKeyMapping(EntityCountQuery query) {
EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityType entityType = resolveEntityType(query.getEntityFilter());
EntityFilterType entityFilterType = query.getEntityFilter().getType(); EntityFilterType entityFilterType = query.getEntityFilter().getType();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
Map<EntityKey, List<KeyFilter>> filters =
query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
int index = 2; int index = 2;
List<EntityKeyMapping> mappings = new ArrayList<>(); List<EntityKeyMapping> mappings = new ArrayList<>();
if (!filters.isEmpty()) {
for (EntityKey filterField : filters.keySet()) { if (operation == ComplexOperation.OR && query.getKeyFilters() != null && !query.getKeyFilters().isEmpty()) {
// Under OR: each KeyFilter gets its own mapping (no same-key grouping)
for (KeyFilter keyFilter : query.getKeyFilters()) {
EntityKeyMapping mapping = new EntityKeyMapping(); EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index); mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index)); mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.get(filterField)); mapping.setKeyFilters(Collections.singletonList(keyFilter));
mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); EntityKey filterKey = keyFilter.getKey();
mapping.setEntityKey(filterField); mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterKey);
mapping.setEntityKeyColumn(entityType, entityFilterType); mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null); mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping); mappings.add(mapping);
index += 1; index += 1;
} }
} else {
// Under AND (or null/default): group same-key filters together
Map<EntityKey, List<KeyFilter>> filters =
query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
if (!filters.isEmpty()) {
for (EntityKey filterField : filters.keySet()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.get(filterField));
mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterField);
mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping);
index += 1;
}
}
} }
return mappings; return mappings;
@ -529,22 +619,27 @@ public class EntityKeyMapping {
private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter,
EntityFilterType filterType) { EntityFilterType filterType) {
return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType); return this.buildKeyQuery(ctx, alias, keyFilter, filterType, false);
}
private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter,
EntityFilterType filterType, boolean useAliasAsField) {
return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType, useAliasAsField);
} }
private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
KeyFilterPredicate predicate, EntityFilterType filterType) { KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
if (predicate.getType().equals(FilterPredicateType.COMPLEX)) { if (predicate.getType().equals(FilterPredicateType.COMPLEX)) {
return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType); return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType, useAliasAsField);
} else { } else {
return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType); return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType, useAliasAsField);
} }
} }
private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
ComplexFilterPredicate predicate, EntityFilterType filterType) { ComplexFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
String result = predicate.getPredicates().stream() String result = predicate.getPredicates().stream()
.map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType)) .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType, useAliasAsField))
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" " + predicate.getOperation().name() + " ")); .collect(Collectors.joining(" " + predicate.getOperation().name() + " "));
if (!result.trim().isEmpty()) { if (!result.trim().isEmpty()) {
@ -554,9 +649,9 @@ public class EntityKeyMapping {
} }
private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
KeyFilterPredicate predicate, EntityFilterType filterType) { KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) { if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) {
String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias; String field = useAliasAsField || getEntityKeyColumn() == null ? alias : alias + "." + getEntityKeyColumn();
if (predicate.getType().equals(FilterPredicateType.NUMERIC)) { if (predicate.getType().equals(FilterPredicateType.NUMERIC)) {
return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate); return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate);
} else if (predicate.getType().equals(FilterPredicateType.STRING)) { } else if (predicate.getType().equals(FilterPredicateType.STRING)) {

163
edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.edqs.repo; package org.thingsboard.server.edqs.repo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
@ -26,7 +27,7 @@ import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation; import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation; import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.EntityKeyValueType;
import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.FilterPredicateValue;
@ -44,6 +45,7 @@ import org.thingsboard.server.edqs.query.DataKey;
import org.thingsboard.server.edqs.query.EdqsFilter; import org.thingsboard.server.edqs.query.EdqsFilter;
import org.thingsboard.server.edqs.util.RepositoryUtils; import org.thingsboard.server.edqs.util.RepositoryUtils;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -469,4 +471,163 @@ public class RepositoryUtilsTest {
return complexFilterPredicate; return complexFilterPredicate;
} }
// --- OR operation tests for checkKeyFilters ---
@Test
public void testCheckKeyFiltersWithOrOperation() {
// Entity with temperature=60; filters: temperature>50 (match) and humidity>80 (no data)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice1").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// OR: temperature>50 matches, short-circuits to true
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithOrOperationNoMatch() {
// Entity with temperature=30; filters: temperature>50 (no match) and temperature<10 (no match)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice2").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 30.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10);
// OR: neither filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrOperationSameKey() {
// Entity with temperature=5; filters: temperature>50 (no match) and temperature<10 (match)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice3").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 5.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10);
// OR on same key: second filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithAndOperationUnchanged() {
// Entity with temperature=60 and humidity=90
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("andDevice1").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// AND: both match
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isTrue();
// Now with humidity=70 -- humidity>80 fails
DeviceData deviceData2 = new DeviceData(UUID.randomUUID());
deviceData2.setCustomerId(UUID.randomUUID());
deviceData2.setFields(DeviceFields.builder().name("andDevice2").build());
deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70));
// AND: humidity>80 fails
assertThat(RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isFalse();
}
@Test
public void testCheckKeyFiltersWithNullOperationDefaultsToAnd() {
// Entity with temperature=60 and humidity=90
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("nullOpDevice").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// null operation should behave like AND
boolean nullResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), null);
boolean andResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND);
assertThat(nullResult).isEqualTo(andResult);
assertThat(nullResult).isTrue();
// Entity with temperature=60 and humidity=70 -- humidity>80 fails
DeviceData deviceData2 = new DeviceData(UUID.randomUUID());
deviceData2.setCustomerId(UUID.randomUUID());
deviceData2.setFields(DeviceFields.builder().name("nullOpDevice2").build());
deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70));
boolean nullResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), null);
boolean andResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND);
assertThat(nullResult2).isEqualTo(andResult2);
assertThat(nullResult2).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrEmptyFilters() {
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("emptyFilterDevice").build());
// OR with empty filters = include all (matches SQL behavior)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithOrStringEntityFieldFilters() {
// Entity with name="loranet device 123"
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("loranet device 123").build());
// Filter 1: name STARTS_WITH "mqtt" (no match)
EdqsFilter nameStartsMqtt = getStringEntityFieldFilter("name", StringOperation.STARTS_WITH, "mqtt");
// Filter 2: name CONTAINS "lora" (match)
EdqsFilter nameContainsLora = getStringEntityFieldFilter("name", StringOperation.CONTAINS, "lora");
// OR: second filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.OR)).isTrue();
// AND: first filter fails
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.AND)).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrEntityFieldNullStringSpecialCase() {
// Entity with name=null — tests the special case where null string + entity field = match
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name(null).build());
// Filter on entity field "label" which is not set (null)
EdqsFilter labelFilter = getStringEntityFieldFilter("label", StringOperation.STARTS_WITH, "test");
// Filter on timeseries (non-entity-field) that doesn't exist
EdqsFilter tempFilter = getTemperatureFilter(NumericOperation.GREATER, 50);
// Entity field with null string = match (special case in evaluateSingleFilter)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.OR)).isTrue();
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.AND)).isTrue();
// Non-entity-field with missing data = no match
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempFilter), ComplexOperation.OR)).isFalse();
// OR: entity field null match + missing timeseries = true (first matches)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.OR)).isTrue();
// AND: entity field null match + missing timeseries = false (second fails)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.AND)).isFalse();
}
} }

3
ui-ngx/src/app/core/api/alarm-data-subscription.ts

@ -23,6 +23,7 @@ import { DatasourceType } from '@shared/models/widget.models';
import { import {
AlarmData, AlarmData,
AlarmDataPageLink, AlarmDataPageLink,
ComplexOperation,
EntityFilter, EntityFilter,
EntityKey, EntityKey,
EntityKeyType, EntityKeyType,
@ -47,6 +48,7 @@ export interface AlarmDataSubscriptionOptions {
pageLink?: AlarmDataPageLink; pageLink?: AlarmDataPageLink;
keyFilters?: Array<KeyFilter>; keyFilters?: Array<KeyFilter>;
additionalKeyFilters?: Array<KeyFilter>; additionalKeyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
subscriptionTimewindow?: SubscriptionTimewindow; subscriptionTimewindow?: SubscriptionTimewindow;
} }
@ -120,6 +122,7 @@ export class AlarmDataSubscription {
entityFilter: this.alarmDataSubscriptionOptions.entityFilter, entityFilter: this.alarmDataSubscriptionOptions.entityFilter,
pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink), pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink),
keyFilters, keyFilters,
keyFiltersOperation: this.alarmDataSubscriptionOptions.keyFiltersOperation,
alarmFields, alarmFields,
entityFields, entityFields,
latestValues latestValues

1
ui-ngx/src/app/core/api/alarm-data.service.ts

@ -85,6 +85,7 @@ export class AlarmDataService {
alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter; alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter;
alarmDataSubscriptionOptions.pageLink = pageLink; alarmDataSubscriptionOptions.pageLink = pageLink;
alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters; alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters;
alarmDataSubscriptionOptions.keyFiltersOperation = alarmSource.keyFiltersOperation;
alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters;
} }
return alarmDataSubscriptionOptions; return alarmDataSubscriptionOptions;

10
ui-ngx/src/app/core/api/alias-controller.ts

@ -314,7 +314,15 @@ export class AliasController implements IAliasController {
|| newDatasource.type === DatasourceType.entityCount || newDatasource.type === DatasourceType.entityCount
|| newDatasource.type === DatasourceType.alarmCount) { || newDatasource.type === DatasourceType.alarmCount) {
if (newDatasource.filterId) { if (newDatasource.filterId) {
newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); const filterInfo = this.getFilterInfo(newDatasource.filterId);
if (filterInfo) {
newDatasource.keyFilters = filterInfoToKeyFilters(filterInfo);
if (filterInfo.keyFiltersOperation) {
newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation;
}
} else {
newDatasource.keyFilters = [];
}
} }
if (newDatasource.type === DatasourceType.alarmCount) { if (newDatasource.type === DatasourceType.alarmCount) {
newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false); newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false);

10
ui-ngx/src/app/core/api/entity-data-subscription.ts

@ -34,6 +34,7 @@ import {
import { import {
AlarmFilter, AlarmFilter,
ComparisonTsValue, ComparisonTsValue,
ComplexOperation,
EntityData, EntityData,
EntityDataPageLink, EntityDataPageLink,
EntityFilter, EntityFilter,
@ -112,6 +113,7 @@ export interface EntityDataSubscriptionOptions {
pageLink?: EntityDataPageLink; pageLink?: EntityDataPageLink;
keyFilters?: Array<KeyFilter>; keyFilters?: Array<KeyFilter>;
additionalKeyFilters?: Array<KeyFilter>; additionalKeyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
subscriptionTimewindow?: SubscriptionTimewindow; subscriptionTimewindow?: SubscriptionTimewindow;
latestTsOffset?: number; latestTsOffset?: number;
} }
@ -356,7 +358,6 @@ export class EntityDataSubscription {
this.subscriber = new TelemetrySubscriber(this.telemetryService); this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.dataCommand = new EntityDataCmd(); this.dataCommand = new EntityDataCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters; let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) { if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) { if (keyFilters) {
@ -370,6 +371,7 @@ export class EntityDataSubscription {
entityFilter: this.entityDataSubscriptionOptions.entityFilter, entityFilter: this.entityDataSubscriptionOptions.entityFilter,
pageLink: this.entityDataSubscriptionOptions.pageLink, pageLink: this.entityDataSubscriptionOptions.pageLink,
keyFilters, keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation,
entityFields, entityFields,
latestValues: this.latestValues latestValues: this.latestValues
}; };
@ -494,7 +496,8 @@ export class EntityDataSubscription {
} }
this.countCommand.query = { this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter, entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation
}; };
this.subscriber.subscriptionCommands.push(this.countCommand); this.subscriber.subscriptionCommands.push(this.countCommand);
@ -569,7 +572,8 @@ export class EntityDataSubscription {
} }
this.alarmCountCommand.query = { this.alarmCountCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter, entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation
}; };
if (this.entityDataSubscriptionOptions.alarmFilter) { if (this.entityDataSubscriptionOptions.alarmFilter) {
this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter}; this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter};

1
ui-ngx/src/app/core/api/entity-data.service.ts

@ -183,6 +183,7 @@ export class EntityDataService {
entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter; entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter;
entityDataSubscriptionOptions.keyFilters = keyFilters; entityDataSubscriptionOptions.keyFilters = keyFilters;
entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters;
entityDataSubscriptionOptions.keyFiltersOperation = datasource.keyFiltersOperation;
if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) { if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) {
entityDataSubscriptionOptions.pageLink = pageLink; entityDataSubscriptionOptions.pageLink = pageLink;
} }

1
ui-ngx/src/app/core/auth/auth.models.ts

@ -40,6 +40,7 @@ export interface SysParamsState {
calculatedFieldDebugPerTenantLimitsConfiguration?: string; calculatedFieldDebugPerTenantLimitsConfiguration?: string;
intermediateAggregationIntervalInSecForCF: number; intermediateAggregationIntervalInSecForCF: number;
trendzSettings: TrendzSettings; trendzSettings: TrendzSettings;
allowKeyFiltersOrConditions: boolean;
} }
export interface SysParams extends SysParamsState { export interface SysParams extends SysParamsState {

3
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -42,7 +42,8 @@ const emptyUserAuthState: AuthPayload = {
maxDebugModeDurationMinutes: 0, maxDebugModeDurationMinutes: 0,
intermediateAggregationIntervalInSecForCF: 0, intermediateAggregationIntervalInSecForCF: 0,
userSettings: initialUserSettings, userSettings: initialUserSettings,
trendzSettings: initialTrendzSettings trendzSettings: initialTrendzSettings,
allowKeyFiltersOrConditions: true
}; };
export const initialState: AuthState = { export const initialState: AuthState = {

23
ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html

@ -26,17 +26,16 @@
</button> </button>
</mat-toolbar> </mat-toolbar>
<div mat-dialog-content> <div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col"> <section class="tb-form-panel">
<mat-form-field class="mat-block"> <div class="flex flex-row items-center justify-between">
<mat-label translate>filter.operation.operation</mat-label> <div class="tb-form-panel-title">{{ 'filter.filters' | translate }}
<mat-select required formControlName="operation"> </div>
@for (operation of complexOperations; track operation) { <tb-toggle-select formControlName="operation"
<mat-option [value]="operation"> selectMediaBreakpoint="xs">
{{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} <tb-toggle-option [value]="complexOperationEnum.AND">{{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }}</tb-toggle-option>
</mat-option> <tb-toggle-option [value]="complexOperationEnum.OR">{{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }}</tb-toggle-option>
} </tb-toggle-select>
</mat-select> </div>
</mat-form-field>
<tb-filter-predicate-list <tb-filter-predicate-list
[valueType]="data.valueType" [valueType]="data.valueType"
[displayUserParameters]="data.displayUserParameters" [displayUserParameters]="data.displayUserParameters"
@ -46,7 +45,7 @@
[key]="data.key" [key]="data.key"
formControlName="predicates"> formControlName="predicates">
</tb-filter-predicate-list> </tb-filter-predicate-list>
</fieldset> </section>
</div> </div>
<div mat-dialog-actions class="flex items-center justify-end"> <div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" <button mat-button color="primary"

77
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html

@ -16,60 +16,61 @@
--> -->
<form [formGroup]="filterFormGroup" (ngSubmit)="save()" style="width: 700px;"> <form [formGroup]="filterFormGroup" (ngSubmit)="save()" style="width: 700px;">
<mat-toolbar color="primary"> <mat-toolbar color="primary" class="justify-between">
<h2>{{ (isAdd ? 'filter.add' : 'filter.edit') | translate }}</h2> <h2>{{ (isAdd ? 'filter.add' : 'filter.edit') | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button <button mat-icon-button
(click)="cancel()" (click)="cancel()"
type="button"> type="button">
<mat-icon class="material-icons">close</mat-icon> <mat-icon class="material-icons">close</mat-icon>
</button> </button>
</mat-toolbar> </mat-toolbar>
@if (isLoading$ | async) { <div mat-dialog-content class="flex flex-col">
<mat-progress-bar color="warn" mode="indeterminate"> <div class="flex flex-row items-start gap-2">
</mat-progress-bar> <mat-form-field class="mat-block flex-1" appearance="outline">
} <mat-label translate>filter.name</mat-label>
<div mat-dialog-content> <input matInput formControlName="filter" required>
<fieldset [disabled]="isLoading$ | async"> @if (filterFormGroup.controls.filter.hasError('required')) {
<div class="flex flex-1 flex-col"> <mat-error>{{ 'filter.name-required' | translate }}</mat-error>
<div class="flex flex-row"> }
<mat-form-field class="mat-block flex-1"> @if (filterFormGroup.controls.filter.hasError('duplicateFilterName')) {
<mat-label translate>filter.name</mat-label> <mat-error>{{ 'filter.duplicate-filter' | translate }}</mat-error>
<input matInput formControlName="filter" required> }
@if (filterFormGroup.get('filter').hasError('required')) { </mat-form-field>
<mat-error> <button mat-stroked-button
{{ 'filter.name-required' | translate }} type="button"
</mat-error> class="tb-rounded-btn mt-2"
} color="primary"
@if (filterFormGroup.get('filter').hasError('duplicateFilterName')) { [class.active]="filterFormGroup.get('editable').value"
<mat-error> (click)="onEditableChange($event)">
{{ 'filter.duplicate-filter' | translate }} <mat-icon [class.invisible]="!filterFormGroup.get('editable').value">checkmark</mat-icon>
</mat-error> <span translate>filter.editable</span>
} </button>
</mat-form-field> </div>
<section class="tb-editable-switch flex flex-col items-center justify-start"> <tb-key-filter-list
<label class="tb-small editable-label" translate>filter.editable</label> formControlName="keyFilters"
<mat-slide-toggle class="editable-switch" [operation]="filterFormGroup.get('keyFiltersOperation').value">
formControlName="editable"> <tb-toggle-select keyFilterHeaderActions
</mat-slide-toggle> formControlName="keyFiltersOperation"
</section> [matTooltip]="!allowKeyFiltersOrConditions ? ('filter.or-conditions-disabled' | translate) : ''"
</div> selectMediaBreakpoint="xs">
<tb-key-filter-list <tb-toggle-option
formControlName="keyFilters"> [value]="ComplexOperation.AND">{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate | titlecase }}
</tb-key-filter-list> </tb-toggle-option>
</div> <tb-toggle-option
</fieldset> [value]="ComplexOperation.OR">{{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate | titlecase }}
</tb-toggle-option>
</tb-toggle-select>
</tb-key-filter-list>
</div> </div>
<div mat-dialog-actions class="flex items-center justify-end"> <div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" <button mat-button color="primary"
type="button" type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial> (click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }} {{ 'action.cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
type="submit" type="submit"
[disabled]="(isLoading$ | async) || filterFormGroup.invalid || !filterFormGroup.dirty"> [disabled]="filterFormGroup.invalid || !filterFormGroup.dirty">
{{ (isAdd ? 'action.add' : 'action.update') | translate }} {{ (isAdd ? 'action.add' : 'action.update') | translate }}
</button> </button>
</div> </div>

28
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss

@ -1,28 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
.tb-editable-switch {
padding-left: 10px;
.editable-switch {
margin: 0;
}
.editable-label {
margin: 5px 0;
}
}
}

87
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts

@ -14,25 +14,23 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
FormGroupDirective,
NgForm,
ValidatorFn,
Validators
} from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core'; import {
import { Filter, Filters } from '@shared/models/query/query.models'; ComplexOperation,
complexOperationTranslationMap,
Filter,
FilterInfo,
Filters
} from '@shared/models/query/query.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { FormControlsFrom } from '@shared/models/tenant.model';
export interface FilterDialogData { export interface FilterDialogData {
isAdd: boolean; isAdd: boolean;
@ -41,32 +39,29 @@ export interface FilterDialogData {
} }
@Component({ @Component({
selector: 'tb-filter-dialog', selector: 'tb-filter-dialog',
templateUrl: './filter-dialog.component.html', templateUrl: './filter-dialog.component.html',
providers: [{ provide: ErrorStateMatcher, useExisting: FilterDialogComponent }], standalone: false
styleUrls: ['./filter-dialog.component.scss'],
standalone: false
}) })
export class FilterDialogComponent extends DialogComponent<FilterDialogComponent, Filter> export class FilterDialogComponent extends DialogComponent<FilterDialogComponent, Filter> {
implements OnInit, ErrorStateMatcher {
isAdd: boolean; isAdd: boolean;
filters: Array<Filter>;
filter: Filter; filterFormGroup: FormGroup<FormControlsFrom<FilterInfo>>;
filterFormGroup: UntypedFormGroup; ComplexOperation = ComplexOperation;
complexOperationTranslationMap = complexOperationTranslationMap;
allowKeyFiltersOrConditions: boolean;
submitted = false; private readonly filter: Filter;
private filters: Array<Filter>;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: FilterDialogData, @Inject(MAT_DIALOG_DATA) public data: FilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, protected dialogRef: MatDialogRef<FilterDialogComponent, Filter>,
public dialogRef: MatDialogRef<FilterDialogComponent, Filter>, private fb: FormBuilder,
private fb: UntypedFormBuilder, private utils: UtilsService) {
private utils: UtilsService,
public translate: TranslateService) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.isAdd = data.isAdd; this.isAdd = data.isAdd;
if (Array.isArray(data.filters)) { if (Array.isArray(data.filters)) {
@ -87,16 +82,25 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
} else { } else {
this.filter = data.filter; this.filter = data.filter;
} }
this.allowKeyFiltersOrConditions = getCurrentAuthState(this.store).allowKeyFiltersOrConditions !== false;
this.filterFormGroup = this.fb.group({ this.filterFormGroup = this.fb.group({
filter: [this.filter.filter, [this.validateDuplicateFilterName(), Validators.required]], filter: [this.filter.filter, [this.validateDuplicateFilterName(), Validators.required]],
editable: [this.filter.editable], editable: [this.filter.editable],
keyFilters: [this.filter.keyFilters, Validators.required] keyFilters: [this.filter.keyFilters, Validators.required],
keyFiltersOperation: [this.filter.keyFiltersOperation ?? ComplexOperation.AND]
}); });
if (!this.allowKeyFiltersOrConditions) {
if (this.filter.keyFiltersOperation === ComplexOperation.OR) {
this.filterFormGroup.controls.keyFiltersOperation.setValue(ComplexOperation.AND);
this.filterFormGroup.markAsDirty();
}
this.filterFormGroup.controls.keyFiltersOperation.disable();
}
} }
validateDuplicateFilterName(): ValidatorFn { validateDuplicateFilterName(): ValidatorFn {
return (c: UntypedFormControl) => { return (c: FormControl<string>) => {
const newFilter = c.value.trim(); const newFilter = c.value.trim();
const found = this.filters.find((filter) => filter.filter === newFilter); const found = this.filters.find((filter) => filter.filter === newFilter);
if (found) { if (found) {
@ -112,13 +116,11 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
}; };
} }
ngOnInit(): void { onEditableChange(event: MouseEvent): void {
} event.stopPropagation();
const editableControl = this.filterFormGroup.controls.editable;
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { editableControl.setValue(!editableControl.value);
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); editableControl.markAsDirty();
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
} }
cancel(): void { cancel(): void {
@ -126,10 +128,11 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
} }
save(): void { save(): void {
this.submitted = true; const {filter, editable, keyFilters, keyFiltersOperation} = this.filterFormGroup.getRawValue();
this.filter.filter = this.filterFormGroup.get('filter').value.trim(); this.filter.filter = filter.trim();
this.filter.editable = this.filterFormGroup.get('editable').value; this.filter.editable = editable;
this.filter.keyFilters = this.filterFormGroup.get('keyFilters').value; this.filter.keyFilters = keyFilters;
this.filter.keyFiltersOperation = keyFiltersOperation;
if (this.isAdd) { if (this.isAdd) {
this.filter.id = this.utils.guid(); this.filter.id = this.utils.guid();
} }

112
ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html

@ -15,82 +15,67 @@
limitations under the License. limitations under the License.
--> -->
<section class="flex flex-col" [formGroup]="filterListFormGroup"> <section class="flex flex-col gap-4" [formGroup]="filterListFormGroup">
<mat-expansion-panel [expanded]="true"> @if (predicatesFormArray.length) {
<mat-expansion-panel-header> <div class="tb-form-panel no-padding no-gap stroked">
<mat-panel-title> <div class="flex flex-row">
<div translate>filter.filters</div> <div class="flex flex-full flex-row items-center justify-start gap-2">
</mat-panel-title> <div class="flex flex-1 flex-row pl-2">
</mat-expansion-panel-header> <label translate class="filter-title max-w-30% flex-full">filter.operation.operation</label>
<div class="flex flex-row"> <label translate class="filter-title max-w-70% flex-full">filter.value</label>
<span class="max-w-8% flex-full"></span>
<div class="flex max-w-92% flex-full flex-row items-center justify-start gap-2">
<div class="flex flex-1 flex-row gap-2">
<div class="flex max-w-30% flex-full flex-row items-center justify-start gap-2">
<label translate class="tb-title no-padding flex-1">filter.operation.operation</label>
@if (valueType === valueTypeEnum.STRING) {
<label
translate class="tb-title no-padding" style="min-width: 70px;">filter.ignore-case</label>
}
</div> </div>
<label translate class="tb-title no-padding max-w-70% flex-full">filter.value</label> @if (displayUserParameters) {
<span [class.!hidden]="disabled" class="min-w-12">&nbsp;</span>
}
</div> </div>
@if (displayUserParameters) {
<label
translate class="tb-title no-padding" style="width: 60px;">filter.user-parameters</label>
}
<span [class.!hidden]="disabled" style="min-width: 40px;">&nbsp;</span>
</div> </div>
</div> <mat-divider style="padding-bottom: 5px;"></mat-divider>
<mat-divider style="padding-bottom: 5px;"></mat-divider> <div class="predicate-list">
<div class="predicate-list"> @for (predicateControl of predicatesFormArray.controls; track predicateControl) {
@for (predicateControl of predicatesFormArray.controls; track predicateControl) { <div class="relative flex flex-row items-stretch justify-start" [class.key-filter-list-divider]="$index">
<div class="flex flex-row items-stretch justify-start h-full min-h-19 max-h-24" @if ($index) {
formArrayName="predicates" <div class="filters-operation-container">
> <span class="filters-operation-label">{{ complexOperationTranslations.get(operation) | translate }}</span>
<div class="filters-operation flex max-w-8% flex-full flex-row items-stretch justify-center"> </div>
@if ($index > 0) {
<span>{{ complexOperationTranslations.get(operation) | translate }}</span>
} }
</div> <div class="flex flex-full flex-col">
<div class="flex max-w-92% min-w-147.5 flex-full flex-col"> <div class="flex flex-1 flex-row items-center justify-start">
<div class="flex flex-1 flex-row items-center justify-start gap-2"> <tb-filter-predicate
<tb-filter-predicate class="flex-1"
class="flex-1" [valueType]="valueType"
[valueType]="valueType" [displayUserParameters]="displayUserParameters"
[displayUserParameters]="displayUserParameters" [allowUserDynamicSource]="allowUserDynamicSource"
[allowUserDynamicSource]="allowUserDynamicSource" [onlyUserDynamicSource]="onlyUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource" [key]="key"
[key]="key" [formControl]="predicateControl">
[formControl]="predicateControl"> </tb-filter-predicate>
</tb-filter-predicate> <button mat-icon-button color="primary"
<button mat-icon-button color="primary" [class.!hidden]="disabled"
[class.!hidden]="disabled" type="button"
type="button" (click)="removePredicate($index)"
(click)="removePredicate($index)" matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltip="{{ 'filter.remove-filter' | translate }}" matTooltipPosition="above">
matTooltipPosition="above"> <mat-icon>close</mat-icon>
<mat-icon>close</mat-icon> </button>
</button> </div>
</div> </div>
</div> </div>
</div> }
} </div>
<span [class.!hidden]="!!predicatesFormArray.length"
[class.disabled]="disabled"
class="no-data-found flex items-center justify-center" translate>filter.no-filters</span>
</div> </div>
<div style="margin-top: 16px;" class="flex flex-row gap-2"> } @else {
<button mat-button mat-raised-button color="primary" <span [class.disabled]="disabled" class="no-data-found tb-prompt flex items-center justify-center" translate>filter.no-filters</span>
[class.!hidden]="disabled" }
<div class="flex flex-row gap-2" [class.!hidden]="disabled">
<button mat-button mat-stroked-button color="primary"
(click)="addPredicate(false)" (click)="addPredicate(false)"
type="button" type="button"
matTooltip="{{ 'filter.add-filter' | translate }}" matTooltip="{{ 'filter.add-filter' | translate }}"
matTooltipPosition="above"> matTooltipPosition="above">
{{ 'action.add' | translate }} {{ 'action.add' | translate }}
</button> </button>
<button mat-button mat-raised-button color="primary" <button mat-button
[class.!hidden]="disabled" color="primary"
(click)="addPredicate(true)" (click)="addPredicate(true)"
type="button" type="button"
matTooltip="{{ 'filter.add-complex-filter' | translate }}" matTooltip="{{ 'filter.add-complex-filter' | translate }}"
@ -98,5 +83,4 @@
{{ 'filter.add-complex' | translate }} {{ 'filter.add-complex' | translate }}
</button> </button>
</div> </div>
</mat-expansion-panel>
</section> </section>

42
ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss

@ -13,18 +13,40 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
@import "../../../../../scss/constants";
:host { :host {
.predicate-list { .filter-title {
overflow-y: auto; padding: 12px 8px;
overflow-x: hidden; font-size: 14px;
max-height: 350px; font-weight: 500;
.no-data-found { }
height: 50px; .no-data-found {
} height: 50px;
font-size: 16px;
}
.key-filter-list-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
} }
.filters-operation { .filters-operation {
margin-top: -18px; display: flex;
color: #666; justify-content: center;
font-weight: 500; margin-top: -14px;
&-container {
position: absolute;
top: -12px;
left: 10px;
background-color: white;
}
&-label {
font-size: 15px;
font-weight: 400;
color: $tb-primary-color;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba($tb-primary-color, 0.32);
background-color: rgba($tb-primary-color, 0.04);
}
} }
} }

121
ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html

@ -15,70 +15,75 @@
limitations under the License. limitations under the License.
--> -->
<div class="flex flex-1 flex-row items-center justify-start gap-2" [formGroup]="filterPredicateValueFormGroup"> <div class="flex flex-1 flex-row items-center justify-between gap-2" [formGroup]="filterPredicateValueFormGroup">
<div class="flex flex-1 flex-col" [class.!hidden]="dynamicMode || onlyUserDynamicSource"> <div class="flex flex-1 flex-col">
<div class="flex flex-col"> <div class="flex flex-1 flex-col" [class.!hidden]="dynamicMode || onlyUserDynamicSource">
@switch (valueType) { <div class="tb-form-row no-padding no-border column-xs">
@case (valueTypeEnum.STRING) { @switch (valueType) {
<mat-form-field hideRequiredMarker class="mat-block"> @case (valueTypeEnum.STRING) {
<input matInput formControlName="defaultValue" placeholder="{{'filter.value' | translate}}"> <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <mat-label> {{ hintText | translate }}</mat-label>
} <input matInput formControlName="defaultValue" placeholder="{{'filter.value' | translate}}">
@case (valueTypeEnum.NUMERIC) { </mat-form-field>
<mat-form-field hideRequiredMarker class="mat-block"> }
<input required type="number" matInput formControlName="defaultValue" @case (valueTypeEnum.NUMERIC) {
placeholder="{{'filter.value' | translate}}"> <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <mat-label> {{ hintText | translate }}</mat-label>
} <input required type="number" matInput formControlName="defaultValue"
@case (valueTypeEnum.DATE_TIME) { placeholder="{{'filter.value' | translate}}">
<tb-datetime formControlName="defaultValue" </mat-form-field>
dateText="{{ 'filter.date' | translate }}" }
required [showLabel]="false"></tb-datetime> @case (valueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="defaultValue"
appearance="outline"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="defaultValue">
{{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
} }
@case (valueTypeEnum.BOOLEAN) { </div>
<mat-checkbox formControlName="defaultValue">
{{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
}
</div> </div>
<div class="tb-hint">{{ hintText | translate }}</div> <div class="flex flex-col flex-1" [class.!hidden]="!dynamicMode && !onlyUserDynamicSource">
</div> <div formGroupName="dynamicValue" class="flex flex-row justify-start gap-2">
<div class="flex flex-col flex-1" [class.!hidden]="!dynamicMode && !onlyUserDynamicSource"> <div class="tb-form-row no-padding no-border max-w-35% flex-full">
<div formGroupName="dynamicValue" class="flex flex-row justify-start gap-2"> <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<div class="flex max-w-35% flex-full flex-col"> <mat-label translate>filter.dynamic-source-type</mat-label>
<mat-form-field hideRequiredMarker class="mat-block"> <mat-select formControlName="sourceType" placeholder="{{'filter.dynamic-source-type' | translate}}">
<mat-select formControlName="sourceType" placeholder="{{'filter.dynamic-source-type' | translate}}"> <mat-option [value]="null">
<mat-option [value]="null"> {{'filter.no-dynamic-value' | translate}}
{{'filter.no-dynamic-value' | translate}}
</mat-option>
@for (sourceType of dynamicValueSourceTypes; track sourceType) {
<mat-option [value]="sourceType">
{{dynamicValueSourceTypeTranslations.get(sourceType) | translate}}
</mat-option> </mat-option>
} @for (sourceType of dynamicValueSourceTypes; track sourceType) {
</mat-select> <mat-option [value]="sourceType">
</mat-form-field> {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}}
<div class="tb-hint" translate>filter.dynamic-source-type</div> </mat-option>
</div> }
<div class="flex flex-1 flex-col"> </mat-select>
<mat-form-field hideRequiredMarker class="mat-block"> </mat-form-field>
<input matInput formControlName="sourceAttribute" placeholder="{{'filter.source-attribute' | translate}}"> </div>
</mat-form-field> <div class="tb-form-row no-padding no-border column-xs flex-full">
<div class="tb-hint" translate>filter.source-attribute</div> <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
</div> <mat-label translate>filter.source-attribute</mat-label>
@if (!allow && inheritMode) { <input matInput formControlName="sourceAttribute" placeholder="{{'filter.source-attribute' | translate}}">
<div </mat-form-field>
class="flex flex-col"
style="padding-top: 6px">
<mat-checkbox formControlName="inherit">
{{ 'filter.inherit-owner' | translate}}
</mat-checkbox>
<div class="tb-hint" translate>filter.source-attribute-not-set</div>
</div> </div>
} @if (!allow && inheritMode) {
<div
class="flex flex-col"
style="padding-top: 6px">
<mat-checkbox formControlName="inherit">
{{ 'filter.inherit-owner' | translate}}
</mat-checkbox>
<div class="tb-hint" translate>filter.source-attribute-not-set</div>
</div>
}
</div>
</div> </div>
</div> </div>
@if (!onlyUserDynamicSource) { @if (!onlyUserDynamicSource) {
<button mat-icon-button <button mat-icon-button
class="mat-elevation-z1 tb-mat-32" class="mat-elevation-z1 tb-mat-32"

101
ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html

@ -15,51 +15,84 @@
limitations under the License. limitations under the License.
--> -->
<div class="flex flex-1 flex-row items-center justify-start gap-2" [formGroup]="filterPredicateFormGroup"> <div class="flex flex-1 flex-row items-center justify-start gap-4" [formGroup]="filterPredicateFormGroup">
<div class="flex flex-1 flex-col"> <div class="tb-form-panel no-border no-padding-right flex-1 items-center" style="flex-direction: row">
@switch (type) { @switch (type) {
@case (filterPredicateType.STRING) { @case (filterPredicateType.STRING) {
<tb-string-filter-predicate <div class="tb-form-row no-border no-padding w-full max-w-30%">
[allowUserDynamicSource]="allowUserDynamicSource" <mat-form-field hideRequiredMarker class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
[onlyUserDynamicSource]="onlyUserDynamicSource" <mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
formControlName="predicate"> @for (operation of stringOperations; track operation) {
</tb-string-filter-predicate> <mat-option [value]="operation">
{{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-chip-listbox formControlName="ignoreCase" [hideSingleSelectionIndicator]="true">
<mat-chip-option color="primary" [value]="true">{{ 'alarm-rule.ignore-case' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
} }
@case (filterPredicateType.NUMERIC) { @case (filterPredicateType.NUMERIC) {
<tb-numeric-filter-predicate <div class="tb-form-row no-border no-padding w-full max-w-30%">
[allowUserDynamicSource]="allowUserDynamicSource" <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
[onlyUserDynamicSource]="onlyUserDynamicSource" <mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
[valueType]="valueType" @for (operation of numericOperations; track operation) {
formControlName="predicate"> <mat-option [value]="operation">
</tb-numeric-filter-predicate> {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} }
@case (filterPredicateType.BOOLEAN) { @case (filterPredicateType.BOOLEAN) {
<tb-boolean-filter-predicate <div class="tb-form-row no-border no-padding w-full max-w-30%">
[allowUserDynamicSource]="allowUserDynamicSource" <mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
[onlyUserDynamicSource]="onlyUserDynamicSource" <mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
formControlName="predicate"> @for (operation of booleanOperations; track operation) {
</tb-boolean-filter-predicate> <mat-option [value]="operation">
{{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
} }
@case (filterPredicateType.COMPLEX) { @case (filterPredicateType.COMPLEX) {
<tb-complex-filter-predicate <div class="tb-form-row no-border no-padding flex-1">
[allowUserDynamicSource]="allowUserDynamicSource" <mat-label translate>filter.complex-filter</mat-label>
[onlyUserDynamicSource]="onlyUserDynamicSource" <button mat-icon-button color="primary"
[key]="key" class="tb-mat-32"
[valueType]="valueType" type="button"
[displayUserParameters]="displayUserParameters" (click)="openComplexFilterDialog()"
formControlName="predicate"> matTooltip="{{ (disabled ? 'filter.complex-filter' : 'filter.edit-complex-filter') | translate }}"
</tb-complex-filter-predicate> matTooltipPosition="above">
<mat-icon>{{ disabled ? 'more_vert' : 'edit' }}</mat-icon>
</button>
</div>
} }
} }
@if (type !== filterPredicateType.COMPLEX) {
<tb-filter-predicate-value
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[operation]="filterPredicateFormGroup.get('operation').value"
class="max-w-70% flex-full"
[valueType]="valueType"
formControlName="value">
</tb-filter-predicate-value>
}
</div> </div>
@if (type !== filterPredicateType.COMPLEX && displayUserParameters) { @if (type !== filterPredicateType.COMPLEX && displayUserParameters) {
<tb-filter-user-info <button mat-icon-button color="primary"
style="width: 60px;" class="tb-mat-32"
class="flex flex-row items-stretch justify-center" type="button"
[valueType]="valueType" (click)="openFilterUserInfoDialog()"
[operation]="filterPredicateFormGroup.get('predicate').value?.operation" matTooltip="{{ (disabled ? 'filter.filter-user-params' : 'filter.edit-filter-user-params') | translate }}"
[key]="key" matTooltipPosition="above">
formControlName="userInfo"> <mat-icon>settings</mat-icon>
</tb-filter-user-info> </button>
} }
</div> </div>

142
ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts

@ -14,19 +14,35 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; import { Component, DestroyRef, forwardRef, Input } from '@angular/core';
import { import {
ControlValueAccessor, ControlValueAccessor,
UntypedFormBuilder, UntypedFormBuilder,
UntypedFormGroup,
NG_VALIDATORS, NG_VALIDATORS,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
ValidationErrors, ValidationErrors,
Validator, Validator,
Validators Validators,
} from '@angular/forms'; } from '@angular/forms';
import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models'; import {
EntityKeyValueType,
FilterPredicateType,
KeyFilterPredicateInfo,
BooleanOperation, booleanOperationTranslationMap,
NumericOperation, numericOperationTranslationMap,
StringOperation, stringOperationTranslationMap, ComplexFilterPredicateInfo, KeyFilterPredicateUserInfo,
KeyFilterPredicate
} from '@shared/models/query/query.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models';
import {
ComplexFilterPredicateDialogComponent
} from '@home/components/filter/complex-filter-predicate-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import {
FilterUserInfoDialogComponent,
FilterUserInfoDialogData
} from '@home/components/filter/filter-user-info-dialog.component';
@Component({ @Component({
selector: 'tb-filter-predicate', selector: 'tb-filter-predicate',
@ -46,7 +62,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
], ],
standalone: false standalone: false
}) })
export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { export class FilterPredicateComponent implements ControlValueAccessor, Validator {
@Input() disabled: boolean; @Input() disabled: boolean;
@ -60,23 +76,35 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
@Input() onlyUserDynamicSource = false; @Input() onlyUserDynamicSource = false;
filterPredicateFormGroup: UntypedFormGroup; filterPredicateFormGroup = this.fb.group({
operation: [],
ignoreCase: false,
predicates: [],
value: [],
userInfo: []
});
type: FilterPredicateType; type: FilterPredicateType;
filterPredicateType = FilterPredicateType; filterPredicateType = FilterPredicateType;
stringOperations = Object.keys(StringOperation);
stringOperationEnum = StringOperation;
stringOperationTranslations = stringOperationTranslationMap;
numericOperations = Object.keys(NumericOperation);
numericOperationEnum = NumericOperation;
numericOperationTranslations = numericOperationTranslationMap;
booleanOperations = Object.keys(BooleanOperation);
booleanOperationEnum = BooleanOperation;
booleanOperationTranslations = booleanOperationTranslationMap;
private propagateChange = null; private propagateChange = null;
constructor(private fb: UntypedFormBuilder, constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef) { private destroyRef: DestroyRef,
} private dialog: MatDialog) {
ngOnInit(): void {
this.filterPredicateFormGroup = this.fb.group({
predicate: [null, [Validators.required]],
userInfo: [null, []]
});
this.filterPredicateFormGroup.valueChanges.pipe( this.filterPredicateFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(() => { ).subscribe(() => {
@ -93,7 +121,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled;
if (this.disabled) { if (isDisabled) {
this.filterPredicateFormGroup.disable({emitEvent: false}); this.filterPredicateFormGroup.disable({emitEvent: false});
} else { } else {
this.filterPredicateFormGroup.enable({emitEvent: false}); this.filterPredicateFormGroup.enable({emitEvent: false});
@ -108,19 +136,97 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
writeValue(predicate: KeyFilterPredicateInfo): void { writeValue(predicate: KeyFilterPredicateInfo): void {
this.type = predicate.keyFilterPredicate.type; this.type = predicate.keyFilterPredicate.type;
this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false}); this.updateValidators();
this.filterPredicateFormGroup.patchValue(predicate.keyFilterPredicate, {emitEvent: false});
this.filterPredicateFormGroup.get('userInfo').patchValue(predicate.userInfo, {emitEvent: false}); this.filterPredicateFormGroup.get('userInfo').patchValue(predicate.userInfo, {emitEvent: false});
} }
private updateValidators(): void {
const operationCtrl = this.filterPredicateFormGroup.get('operation');
const predicatesCtrl = this.filterPredicateFormGroup.get('predicates');
operationCtrl.setValidators([Validators.required]);
if (this.type === FilterPredicateType.COMPLEX) {
predicatesCtrl.setValidators([Validators.required]);
} else {
predicatesCtrl.clearValidators();
}
operationCtrl.updateValueAndValidity({emitEvent: false});
predicatesCtrl.updateValueAndValidity({emitEvent: false});
}
private updateModel() { private updateModel() {
let predicate: KeyFilterPredicateInfo = null; let predicate: KeyFilterPredicateInfo = null;
if (this.filterPredicateFormGroup.valid) { if (this.filterPredicateFormGroup.valid) {
const v = this.filterPredicateFormGroup.getRawValue();
let keyFilterPredicate: KeyFilterPredicate;
if (this.type === FilterPredicateType.COMPLEX) {
keyFilterPredicate = {
type: FilterPredicateType.COMPLEX,
operation: v.operation,
predicates: v.predicates
} as KeyFilterPredicate;
} else {
keyFilterPredicate = {
type: this.type,
value: v.value,
operation: v.operation,
ignoreCase: !!v.ignoreCase
} as KeyFilterPredicate;
}
predicate = { predicate = {
keyFilterPredicate: this.filterPredicateFormGroup.getRawValue().predicate, keyFilterPredicate,
userInfo: this.filterPredicateFormGroup.getRawValue().userInfo userInfo: v.userInfo
}; };
} }
this.propagateChange(predicate); this.propagateChange(predicate);
} }
public openComplexFilterDialog() {
this.dialog.open<ComplexFilterPredicateDialogComponent, ComplexFilterPredicateDialogData,
ComplexFilterPredicateInfo>(ComplexFilterPredicateDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
complexPredicate: {
type: FilterPredicateType.COMPLEX,
operation: this.filterPredicateFormGroup.get('operation').value,
predicates: this.filterPredicateFormGroup.get('predicates').value
},
readonly: this.disabled,
valueType: this.valueType,
isAdd: false,
key: this.key,
displayUserParameters: this.displayUserParameters,
allowUserDynamicSource: this.allowUserDynamicSource,
onlyUserDynamicSource: this.onlyUserDynamicSource
}
}).afterClosed().subscribe(
(result) => {
if (result) {
this.filterPredicateFormGroup.patchValue(result);
}
}
);
}
public openFilterUserInfoDialog() {
this.dialog.open<FilterUserInfoDialogComponent, FilterUserInfoDialogData,
KeyFilterPredicateUserInfo>(FilterUserInfoDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
keyFilterPredicateUserInfo: this.filterPredicateFormGroup.get('userInfo').value,
valueType: this.valueType,
key: this.key,
operation: this.filterPredicateFormGroup.get('operation').value,
readonly: this.disabled
}
}).afterClosed().subscribe(
(result) => {
if (result) {
this.filterPredicateFormGroup.get('userInfo').patchValue(result);
}
}
);
}
} }

65
ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts

@ -14,37 +14,31 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { ComplexOperation, KeyFilter, keyFiltersToText } from '@shared/models/query/query.models';
import { KeyFilter, keyFiltersToText } from '@shared/models/query/query.models';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-filter-text', selector: 'tb-filter-text',
templateUrl: './filter-text.component.html', templateUrl: './filter-text.component.html',
styleUrls: ['./filter-text.component.scss'], styleUrls: ['./filter-text.component.scss'],
providers: [ providers: [
{ {
provide: NG_VALUE_ACCESSOR, provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FilterTextComponent), useExisting: forwardRef(() => FilterTextComponent),
multi: true multi: true
} }
], ],
standalone: false standalone: false
}) })
export class FilterTextComponent implements ControlValueAccessor, OnInit { export class FilterTextComponent implements ControlValueAccessor, OnChanges {
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input() @Input()
set required(value: boolean) { @coerceBoolean()
this.requiredValue = coerceBooleanProperty(value); required = false;
}
@Input() @Input()
disabled: boolean; disabled: boolean;
@ -58,26 +52,30 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit {
@Input() @Input()
nowrap = false; nowrap = false;
@Input()
operation: ComplexOperation = ComplexOperation.AND;
requiredClass = false; requiredClass = false;
public filterText: string; public filterText: string;
private propagateChange = (v: any) => { }; private currentValue: Array<KeyFilter>;
constructor(private dialog: MatDialog, constructor(private translate: TranslateService,
private fb: UntypedFormBuilder,
private translate: TranslateService,
private datePipe: DatePipe) { private datePipe: DatePipe) {
} }
registerOnChange(fn: any): void { registerOnChange(_fn: any): void {
this.propagateChange = fn;
} }
registerOnTouched(fn: any): void { registerOnTouched(_fn: any): void {
} }
ngOnInit() { ngOnChanges(changes: SimpleChanges) {
if (changes.operation && !changes.operation.firstChange
&& changes.operation.currentValue !== changes.operation.previousValue) {
this.updateFilterText(this.currentValue);
}
} }
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
@ -85,13 +83,14 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit {
} }
writeValue(value: Array<KeyFilter>): void { writeValue(value: Array<KeyFilter>): void {
this.currentValue = value;
this.updateFilterText(value); this.updateFilterText(value);
} }
private updateFilterText(value: Array<KeyFilter>) { private updateFilterText(value: Array<KeyFilter>) {
this.requiredClass = false; this.requiredClass = false;
if (value && value.length) { if (value && value.length) {
this.filterText = keyFiltersToText(this.translate, this.datePipe, value); this.filterText = keyFiltersToText(this.translate, this.datePipe, value, this.operation);
} else { } else {
if (this.required && !this.disabled) { if (this.required && !this.disabled) {
this.filterText = this.addFilterPrompt; this.filterText = this.addFilterPrompt;

111
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html

@ -25,72 +25,61 @@
<mat-icon class="material-icons">close</mat-icon> <mat-icon class="material-icons">close</mat-icon>
</button> </button>
</mat-toolbar> </mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
@if ((isLoading$ | async) === false) {
<div style="height: 4px;"></div>
}
<div mat-dialog-content> <div mat-dialog-content>
<div class="tb-filters-header flex flex-row items-center justify-start"> <div class="tb-form-panel no-border no-padding">
<span class="max-w-5% flex-full"></span> <div class="tb-form-panel stroked no-padding no-gap" [class.!hidden]="!filtersFormArray().length">
<div class="flex max-w-95% flex-full flex-row items-center justify-start"> <table mat-table [dataSource]="filtersFormArray().controls" class="overflow-hidden bg-transparent">
<div class="tb-header-label flex-1" translate>filter.filter</div> <ng-container matColumnDef="filter">
<div class="tb-header-label flex min-w-20 max-w-20 flex-1 basis-20 flex-col items-center justify-center" translate <mat-header-cell *matHeaderCellDef translate>filter.filter</mat-header-cell>
style="padding-left: 5px;">filter.editable</div> <mat-cell *matCellDef="let control">
<div style="min-width: 132px;"></div> <span>{{ control.get('filter').value }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="editable">
<mat-header-cell *matHeaderCellDef translate>filter.editable</mat-header-cell>
<mat-cell *matCellDef="let control">
<mat-slide-toggle [formControl]="control.get('editable')"></mat-slide-toggle>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef/>
<mat-cell *matCellDef="let control; let i = index">
<div class="tb-form-table-row-cell-buttons flex-end">
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="editFilter(i)"
matTooltip="{{ 'filter.edit' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="duplicateFilter(i)"
matTooltip="{{ 'filter.duplicate-filter-action' | translate }}"
matTooltipPosition="above">
<mat-icon>content_copy</mat-icon>
</button>
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="removeFilter(i)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</table>
</div>
<div [class.!hidden]="!!filtersFormArray().length" class="tb-prompt flex items-center justify-center" translate>
filter.no-filters
</div> </div>
</div> </div>
<fieldset [disabled]="isLoading$ | async">
<mat-divider></mat-divider>
@for (filterControl of filtersFormArray().controls; track filterControl) {
<div class="flex flex-1 flex-row items-center justify-start"
formArrayName="filters"
>
<span class="max-w-5% flex-full">{{$index + 1}}.</span>
<div class="mat-elevation-z4 tb-filter flex max-w-95% flex-full flex-row items-center justify-start">
<mat-label class="flex-1">{{filterControl.get('filter').value}}</mat-label>
<section style="padding-left: 10px;"
class="tb-editable-switch flex min-w-20 max-w-20 flex-1 basis-20 flex-col items-center justify-center">
<mat-slide-toggle class="editable-switch"
[formControl]="filterControl.get('editable')">
</mat-slide-toggle>
</section>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 40px;"
type="button"
(click)="editFilter($index)"
matTooltip="{{ 'filter.edit' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 48px;"
type="button"
(click)="duplicateFilter($index)"
matTooltip="{{ 'filter.duplicate-filter-action' | translate }}"
matTooltipPosition="above">
<mat-icon>content_copy</mat-icon>
</button>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 40px;"
type="button"
(click)="removeFilter($index)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</fieldset>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-raised-button color="primary" <button mat-stroked-button color="primary"
type="button" type="button"
(click)="addFilter()" (click)="addFilter()"
[class.!hidden]="disableAdd" [class.!hidden]="disableAdd"

42
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss

@ -14,39 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
.mat-mdc-header-cell.mat-column-editable,
.tb-filters-header { .mat-mdc-cell.mat-column-editable,
min-height: 40px; .mat-mdc-header-cell.mat-column-actions,
padding: 0 11px; .mat-mdc-cell.mat-column-actions {
margin: 5px; width: 1%;
white-space: nowrap;
.tb-header-label {
font-size: 14px;
color: rgba(0, 0, 0, .570588);
}
}
mat-divider{
margin: -1px -24px;
}
.tb-filter {
padding: 0 0 0 10px;
margin: 5px;
.tb-editable-switch {
padding-left: 10px;
.editable-switch {
margin: 0;
}
}
} }
}
:host ::ng-deep { .mat-mdc-row:last-child .mat-mdc-cell {
.mat-mdc-dialog-content { border-bottom: none;
padding-top: 0 !important;
padding-bottom: 0 !important;
} }
} }

11
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts

@ -37,7 +37,7 @@ import { TranslateService } from '@ngx-translate/core';
import { ActionNotificationShow } from '@core/notification/notification.actions'; import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { deepClone, isUndefined } from '@core/utils'; import { deepClone, isUndefined } from '@core/utils';
import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; import { ComplexOperation, Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models';
import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component'; import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@ -70,6 +70,8 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
filtersFormGroup: UntypedFormGroup; filtersFormGroup: UntypedFormGroup;
displayedColumns = ['filter', 'editable', 'actions'];
submitted = false; submitted = false;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
@ -132,7 +134,8 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
id: [filterId], id: [filterId],
filter: [filter ? filter.filter : null, [Validators.required]], filter: [filter ? filter.filter : null, [Validators.required]],
keyFilters: [filter ? filter.keyFilters : [], [Validators.required]], keyFilters: [filter ? filter.keyFilters : [], [Validators.required]],
editable: [filter ? filter.editable : true] editable: [filter ? filter.editable : true],
keyFiltersOperation: [filter?.keyFiltersOperation]
}); });
return filterFormControl; return filterFormControl;
} }
@ -228,6 +231,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
filterFormControl.get('filter').patchValue(result.filter); filterFormControl.get('filter').patchValue(result.filter);
filterFormControl.get('editable').patchValue(result.editable); filterFormControl.get('editable').patchValue(result.editable);
filterFormControl.get('keyFilters').patchValue(result.keyFilters); filterFormControl.get('keyFilters').patchValue(result.keyFilters);
filterFormControl.get('keyFiltersOperation').patchValue(result.keyFiltersOperation);
} }
this.filterNames.add(result.filter); this.filterNames.add(result.filter);
this.filtersFormGroup.markAsDirty(); this.filtersFormGroup.markAsDirty();
@ -253,6 +257,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
const filter: string = filterValue.filter; const filter: string = filterValue.filter;
const keyFilters: Array<KeyFilterInfo> = filterValue.keyFilters; const keyFilters: Array<KeyFilterInfo> = filterValue.keyFilters;
const editable: boolean = filterValue.editable; const editable: boolean = filterValue.editable;
const keyFiltersOperation: ComplexOperation = filterValue.keyFiltersOperation;
if (uniqueFilterList[filter]) { if (uniqueFilterList[filter]) {
valid = false; valid = false;
message = this.translate.instant('filter.duplicate-filter-error', {filter}); message = this.translate.instant('filter.duplicate-filter-error', {filter});
@ -263,7 +268,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
break; break;
} else { } else {
uniqueFilterList[filter] = filter; uniqueFilterList[filter] = filter;
filters[filterId] = {id: filterId, filter, keyFilters, editable}; filters[filterId] = {id: filterId, filter, keyFilters, editable, keyFiltersOperation};
} }
} }
if (valid) { if (valid) {

205
ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html

@ -26,119 +26,128 @@
</button> </button>
</mat-toolbar> </mat-toolbar>
<div mat-dialog-content> <div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col"> <section class="tb-form-panel no-border no-padding">
<section class="entity-key flex flex-row gap-2"> <section class="tb-form-panel">
<section class="flex flex-full flex-row gap-2" <div class="tb-form-panel-title">{{ 'filter.key-filter' | translate }}</div>
[class]="{ <div class="flex flex-row gap-2 xs:flex-col">
<div class="flex flex-row flex-full gap-2 xs:flex-col"
[class]="{
'max-w-45%': isConstantKeyType, 'max-w-45%': isConstantKeyType,
'max-w-70%': !isConstantKeyType 'max-w-70%': !isConstantKeyType
}" }"
formGroupName="key"> formGroupName="key">
<mat-form-field class="mat-block max-w-40% flex-full"> <mat-form-field class="mat-block max-w-40% flex-full" appearance="outline">
<mat-label translate>filter.key-type.key-type</mat-label> <mat-label translate>filter.key-type.key-type</mat-label>
<mat-select required formControlName="type"> <mat-select required formControlName="type">
@for (type of entityKeyTypes; track type) { @for (type of entityKeyTypes; track type) {
<mat-option [value]="type"> <mat-option [value]="type">
{{entityKeyTypeTranslations.get(type) | translate}} {{entityKeyTypeTranslations.get(type) | translate}}
</mat-option> </mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block max-w-60% flex-full" appearance="outline">
<mat-label translate>filter.key-name</mat-label>
<input matInput required formControlName="key"
#keyNameInput
(focusin)="onFocus()"
[matAutocomplete]="keyName"
[matAutocompleteDisabled]="!showAutocomplete">
@if (keyFilterFormGroup.get('key.key').value && showAutocomplete) {
<button
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
} }
</mat-select> <mat-autocomplete autoActiveFirstOption #keyName="matAutocomplete">
</mat-form-field> @for (keyName of filteredKeysName | async; track keyName) {
<mat-form-field class="mat-block max-w-60% flex-full"> <mat-option [value]="keyName">
<mat-label translate>filter.key-name</mat-label> <span [innerHTML]="keyName | highlight:searchText"></span>
<input matInput required formControlName="key" </mat-option>
#keyNameInput }
(focusin)="onFocus()" </mat-autocomplete>
[matAutocomplete]="keyName" @if (keyFilterFormGroup.get('key.key').hasError('required')) {
[matAutocompleteDisabled]="!showAutocomplete"> <mat-error>
@if (keyFilterFormGroup.get('key.key').value && showAutocomplete) { {{ 'filter.key-name-required' | translate }}
<button </mat-error>
type="button" }
matSuffix mat-icon-button aria-label="Clear" </mat-form-field>
(click)="clear()"> </div>
<mat-icon class="material-icons">close</mat-icon> <mat-form-field class="mat-block tb-value-type flex-full" appearance="outline"
</button> [class]="{
} 'max-w-20%': isConstantKeyType,
<mat-autocomplete autoActiveFirstOption #keyName="matAutocomplete"> 'max-w-30%': !isConstantKeyType
@for (keyName of filteredKeysName | async; track keyName) { }">
<mat-option [value]="keyName"> <mat-label translate>filter.value-type.value-type</mat-label>
<span [innerHTML]="keyName | highlight:searchText"></span> <mat-select formControlName="valueType">
<mat-select-trigger>
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }}</span>
</mat-select-trigger>
@for (valueType of entityKeyValueTypesKeys; track valueType) {
<mat-option [value]="valueType">
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }}</span>
</mat-option> </mat-option>
} }
</mat-autocomplete> </mat-select>
@if (keyFilterFormGroup.get('key.key').hasError('required')) { @if (keyFilterFormGroup.get('valueType').hasError('required')) {
<mat-error> <mat-error>
{{ 'filter.key-name-required' | translate }} {{ 'filter.value-type-required' | translate }}
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
</section> @if (isConstantKeyType) {
<mat-form-field class="mat-block tb-value-type flex-full" <div class="flex-1">
[class]="{ <div class="flex flex-col">
'max-w-20%': isConstantKeyType, @switch (keyFilterFormGroup.get('valueType').value) {
'max-w-30%': !isConstantKeyType @case (entityKeyValueTypeEnum.STRING) {
}"> <mat-form-field class="mat-block" appearance="outline">
<mat-label translate>filter.value-type.value-type</mat-label> <mat-label translate>filter.value</mat-label>
<mat-select formControlName="valueType"> <input matInput required formControlName="value" placeholder="{{'filter.value' | translate}}">
<mat-select-trigger> </mat-form-field>
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.icon }}"></mat-icon> }
<span>{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }}</span> @case (entityKeyValueTypeEnum.NUMERIC) {
</mat-select-trigger> <mat-form-field class="mat-block" appearance="outline">
@for (valueType of entityKeyValueTypesKeys; track valueType) { <mat-label translate>filter.value</mat-label>
<mat-option [value]="valueType"> <input required type="number" matInput formControlName="value">
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).icon }}"></mat-icon> </mat-form-field>
<span>{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }}</span> }
</mat-option> @case (entityKeyValueTypeEnum.DATE_TIME) {
} <tb-datetime formControlName="value"
</mat-select> dateText="{{ 'filter.date' | translate }}"
@if (keyFilterFormGroup.get('valueType').hasError('required')) { required></tb-datetime>
<mat-error> }
{{ 'filter.value-type-required' | translate }} @case (entityKeyValueTypeEnum.BOOLEAN) {
</mat-error> <mat-checkbox formControlName="value" style="margin-top: 20px">
} {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }}
</mat-form-field> </mat-checkbox>
@if (isConstantKeyType) { }
<div class="flex-1">
<div class="flex flex-col">
@switch (keyFilterFormGroup.get('valueType').value) {
@case (entityKeyValueTypeEnum.STRING) {
<mat-form-field class="mat-block">
<mat-label translate>filter.value</mat-label>
<input matInput required formControlName="value" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.NUMERIC) {
<mat-form-field class="mat-block">
<mat-label translate>filter.value</mat-label>
<input required type="number" matInput formControlName="value">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="value"
dateText="{{ 'filter.date' | translate }}"
required></tb-datetime>
}
@case (entityKeyValueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="value" style="margin-top: 20px">
{{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
} }
} </div>
</div> </div>
</div> }
} </div>
</section> </section>
@if (keyFilterFormGroup.get('valueType').value) { @if (keyFilterFormGroup.get('valueType').value) {
<tb-filter-predicate-list <section class="tb-form-panel">
[allowUserDynamicSource]="data.allowUserDynamicSource" <div class="flex flex-row items-center justify-between">
[onlyUserDynamicSource]="isConstantKeyType" <div class="tb-form-panel-title">{{ 'filter.filters' | translate }}
[displayUserParameters]="data.displayUserParameters" </div>
[valueType]="keyFilterFormGroup.get('valueType').value" </div>
[key]="keyFilterFormGroup.get('key.key').value" <tb-filter-predicate-list
formControlName="predicates"> [allowUserDynamicSource]="data.allowUserDynamicSource"
</tb-filter-predicate-list> [onlyUserDynamicSource]="isConstantKeyType"
[displayUserParameters]="data.displayUserParameters"
[valueType]="keyFilterFormGroup.get('valueType').value"
[key]="keyFilterFormGroup.get('key.key').value"
formControlName="predicates">
</tb-filter-predicate-list>
</section>
} }
</fieldset> </section>
</div> </div>
<div mat-dialog-actions class="flex items-center justify-end"> <div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" <button mat-button color="primary"

148
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html

@ -15,82 +15,86 @@
limitations under the License. limitations under the License.
--> -->
<section class="flex flex-col" [formGroup]="keyFilterListFormGroup"> <section class="tb-form-panel no-padding no-border">
<mat-accordion [multi]="true"> <div class="tb-form-panel stroked">
<mat-expansion-panel [expanded]="true"> <div class="flex flex-row items-center justify-between">
<mat-expansion-panel-header> <div class="tb-form-panel-title" translate>filter.key-filters</div>
<mat-panel-title> <ng-content select="[keyFilterHeaderActions]"></ng-content>
<div translate>filter.key-filters</div> </div>
</mat-panel-title> @if (keyFiltersFormArray.length) {
</mat-expansion-panel-header> <section class="tb-form-panel no-padding no-gap stroked">
<div class="flex flex-row"> <div class="flex flex-row">
<span class="max-w-8% flex-full"></span> <span class="min-w-16"></span>
<div class="flex max-w-92% flex-full flex-row items-center justify-start"> <div class="flex flex-1 flex-row items-center justify-start">
<label translate class="tb-title no-padding flex-1">filter.key-name</label> <div translate class="filter-title flex-1">filter.key-name</div>
<label translate class="tb-title no-padding flex-1">filter.key-type.key-type</label> <div translate class="filter-title flex-1">filter.key-type.key-type</div>
<span [class.!hidden]="disabled" style="min-width: 80px;">&nbsp;</span> <span [class.!hidden]="disabled" style="min-width: 96px;">&nbsp;</span>
<span [class.!hidden]="!disabled" style="min-width: 40px;">&nbsp;</span> <span [class.!hidden]="!disabled" style="min-width: 48px;">&nbsp;</span>
</div>
</div> </div>
</div> <mat-divider></mat-divider>
<mat-divider></mat-divider> <div class="filter-list">
<div class="key-filter-list"> @for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl; let index = $index) {
@for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl) { <div class="flex flex-row items-stretch justify-start"
<div class="flex flex-row items-stretch justify-start" style="max-height: 76px;" [class.filter-list-divider]="index">
formArrayName="keyFilters" <div class="filters-operation min-w-16">
> @if (index) {
<div class="filters-operation max-w-8% flex-full"> <div class="filters-operation-container">
@if ($index > 0) { <span class="filters-operation-label">{{ complexOperationTranslationMap.get(operation) | translate }}</span>
<span translate>filter.operation.and</span> </div>
} }
</div> </div>
<div class="flex max-w-92% flex-full flex-col"> <div class="flex flex-1 flex-col">
<div class="flex flex-row items-center justify-start"> <div class="flex flex-row items-center justify-start">
<div class="flex-1">{{ keyFilterControl.value.key.key }}</div> <div class="filters-text flex-1" tbTruncateWithTooltip>{{ keyFilterControl.value.key.key }}</div>
<div class="flex-1">{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}</div> <div class="filters-text flex-1" tbTruncateWithTooltip>{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}</div>
<button mat-icon-button color="primary" <button mat-icon-button color="primary"
type="button" type="button"
(click)="editKeyFilter($index)" (click)="editKeyFilter(index)"
matTooltip="{{ (disabled ? 'filter.key-filter' : 'filter.edit-key-filter') | translate }}" matTooltip="{{ (disabled ? 'filter.key-filter' : 'filter.edit-key-filter') | translate }}"
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon>{{disabled ? 'more_vert' : 'edit'}}</mat-icon> <mat-icon>{{disabled ? 'visibility' : 'edit'}}</mat-icon>
</button> </button>
<button mat-icon-button color="primary" <button mat-icon-button color="primary"
[class.!hidden]="disabled" [class.!hidden]="disabled"
type="button" type="button"
(click)="removeKeyFilter($index)" (click)="removeKeyFilter(index)"
matTooltip="{{ 'filter.remove-key-filter' | translate }}" matTooltip="{{ 'filter.remove-key-filter' | translate }}"
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</div>
</div> </div>
<mat-divider></mat-divider>
</div> </div>
</div> }
} </div>
<span [class.!hidden]="!!keyFiltersFormArray.length" </section>
[class.disabled]="disabled" } @else {
class="no-data-found flex items-center justify-center" translate>filter.no-key-filters</span> <span class="no-data-found tb-prompt flex items-center justify-center"
</div> [class.required]="!disabled"
<div style="margin-top: 16px;"> translate>filter.no-key-filters</span>
<button mat-button mat-raised-button color="primary" }
[class.!hidden]="disabled" <div>
(click)="addKeyFilter()" <button mat-button mat-stroked-button color="primary"
type="button" [class.!hidden]="disabled"
matTooltip="{{ 'filter.add-key-filter' | translate }}" (click)="addKeyFilter()"
matTooltipPosition="above"> type="button">
{{ 'filter.add-key-filter' | translate }} {{ 'filter.add-key-filter' | translate }}
</button> </button>
</div> </div>
</mat-expansion-panel> </div>
<mat-expansion-panel [expanded]="true"> <div class="tb-form-panel no-gap stroked">
<mat-expansion-panel class="tb-settings" [expanded]="true">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> {{ 'filter.preview' | translate }}
<div translate>filter.preview</div>
</mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div class="tb-filter-preview"> <ng-template matExpansionPanelContent>
<tb-filter-text [formControl]="keyFiltersControl"></tb-filter-text> @if (keyFiltersFormArray.length) {
</div> <tb-filter-text [formControl]="keyFiltersControl" [operation]="operation"></tb-filter-text>
} @else {
<span class="tb-prompt justify-center text-base" translate>filter.no-filter-text</span>
}
</ng-template>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </div>
</section> </section>

51
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss

@ -13,30 +13,45 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
@import "../scss/constants";
:host { :host {
.key-filter-list { .filter-title {
padding: 12px 0;
font-size: 14px;
font-weight: 500;
}
.no-data-found {
height: 50px;
font-size: 16px;
}
.filter-list {
overflow: auto; overflow: auto;
max-height: 300px; max-height: 300px;
.no-data-found {
height: 50px; &-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.filters-text {
font-size: 14px;
} }
} }
.filters-operation { .filters-operation {
margin-top: -18px; display: flex;
color: #666; justify-content: center;
font-weight: 500; margin-top: -14px;
} &-container {
.tb-filter-preview { background-color: white;
padding: 8px; }
border: 1px groove rgba(0, 0, 0, .25); &-label {
border-radius: 4px; font-size: 15px;
} font-weight: 400;
} color: $tb-primary-color;
padding: 0 8px;
:host ::ng-deep { border-radius: 4px;
.tb-filter-preview { border: 1px solid rgba($tb-primary-color, 0.32);
.tb-filter-text { background-color: rgba($tb-primary-color, 0.04);
max-height: 200px;
} }
} }
} }

126
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts

@ -14,24 +14,26 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import { import {
AbstractControl,
ControlValueAccessor, ControlValueAccessor,
UntypedFormArray, FormArray,
UntypedFormBuilder, FormBuilder,
UntypedFormControl, FormControl,
UntypedFormGroup,
NG_VALIDATORS, NG_VALIDATORS,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
ValidationErrors, ValidationErrors,
Validator, Validator,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { Observable, Subject } from 'rxjs'; import { Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
ComplexOperation,
complexOperationTranslationMap,
EntityKeyType, EntityKeyType,
entityKeyTypeTranslationMap, entityKeyTypeTranslationMap,
KeyFilter,
KeyFilterInfo, KeyFilterInfo,
keyFilterInfosToKeyFilters keyFilterInfosToKeyFilters
} from '@shared/models/query/query.models'; } from '@shared/models/query/query.models';
@ -39,27 +41,26 @@ import { MatDialog } from '@angular/material/dialog';
import { deepClone } from '@core/utils'; import { deepClone } from '@core/utils';
import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component'; import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'tb-key-filter-list', selector: 'tb-key-filter-list',
templateUrl: './key-filter-list.component.html', templateUrl: './key-filter-list.component.html',
styleUrls: ['./key-filter-list.component.scss'], styleUrls: ['./key-filter-list.component.scss'],
providers: [ providers: [
{ {
provide: NG_VALUE_ACCESSOR, provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KeyFilterListComponent), useExisting: forwardRef(() => KeyFilterListComponent),
multi: true multi: true
}, },
{ {
provide: NG_VALIDATORS, provide: NG_VALIDATORS,
useExisting: forwardRef(() => KeyFilterListComponent), useExisting: forwardRef(() => KeyFilterListComponent),
multi: true multi: true
} }
], ],
standalone: false standalone: false
}) })
export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit {
@Input() disabled: boolean; @Input() disabled: boolean;
@ -71,39 +72,32 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
@Input() entityId: EntityId; @Input() entityId: EntityId;
keyFilterListFormGroup: UntypedFormGroup; @Input() operation: ComplexOperation = ComplexOperation.AND;
complexOperationTranslationMap = complexOperationTranslationMap;
entityKeyTypeTranslations = entityKeyTypeTranslationMap; entityKeyTypeTranslations = entityKeyTypeTranslationMap;
keyFiltersControl: UntypedFormControl; keyFiltersFormArray: FormArray<FormControl<KeyFilterInfo>>;
keyFiltersControl: FormControl<Array<KeyFilter>>;
private destroy$ = new Subject<void>();
private propagateChange = null; private propagateChange = null;
constructor(private fb: UntypedFormBuilder, constructor(private fb: FormBuilder,
private dialog: MatDialog) { private dialog: MatDialog,
private destroyRef: DestroyRef) {
} }
ngOnInit(): void { ngOnInit(): void {
this.keyFilterListFormGroup = this.fb.group({ this.keyFiltersFormArray = this.fb.array<FormControl<KeyFilterInfo>>([]);
keyFilters: this.fb.array([])
});
this.keyFiltersControl = this.fb.control(null); this.keyFiltersControl = this.fb.control(null);
this.keyFilterListFormGroup.valueChanges.pipe( this.keyFiltersFormArray.valueChanges.pipe(
takeUntil(this.destroy$) takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateModel()); ).subscribe(() => this.updateModel());
} }
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get keyFiltersFormArray(): UntypedFormArray {
return this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray;
}
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
this.propagateChange = fn; this.propagateChange = fn;
} }
@ -114,61 +108,61 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
setDisabledState?(isDisabled: boolean): void { setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled;
if (this.disabled) { if (this.disabled) {
this.keyFilterListFormGroup.disable({emitEvent: false}); this.keyFiltersFormArray.disable({emitEvent: false});
this.keyFiltersControl.disable({emitEvent: false}); this.keyFiltersControl.disable({emitEvent: false});
} else { } else {
this.keyFilterListFormGroup.enable({emitEvent: false}); this.keyFiltersFormArray.enable({emitEvent: false});
this.keyFiltersControl.enable({emitEvent: false}); this.keyFiltersControl.enable({emitEvent: false});
} }
} }
validate(): ValidationErrors | null { validate(): ValidationErrors | null {
return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : { return this.keyFiltersFormArray.valid && this.keyFiltersControl.valid ? null : {
keyFilterList: {valid: false} keyFilterList: {valid: false}
}; };
} }
writeValue(keyFilters: Array<KeyFilterInfo>): void { writeValue(keyFilters: Array<KeyFilterInfo>): void {
if (keyFilters.length === this.keyFiltersFormArray.length) { if (keyFilters?.length === this.keyFiltersFormArray.length) {
this.keyFiltersFormArray.patchValue(keyFilters, {emitEvent: false}); this.keyFiltersFormArray.patchValue(keyFilters, {emitEvent: false});
} else { } else {
const keyFilterControls: Array<AbstractControl> = []; this.keyFiltersFormArray.clear({emitEvent: false});
if (keyFilters) { if (keyFilters) {
for (const keyFilter of keyFilters) { for (const keyFilter of keyFilters) {
keyFilterControls.push(this.fb.control(keyFilter, [Validators.required])); this.keyFiltersFormArray.push(
this.fb.control(keyFilter, [Validators.required]),
{emitEvent: false}
);
} }
} }
this.keyFilterListFormGroup.setControl('keyFilters', this.fb.array(keyFilterControls), {emitEvent: false});
if (this.disabled) { if (this.disabled) {
this.keyFilterListFormGroup.disable({emitEvent: false}); this.keyFiltersFormArray.disable({emitEvent: false});
} else { } else {
this.keyFilterListFormGroup.enable({emitEvent: false}); this.keyFiltersFormArray.enable({emitEvent: false});
} }
} }
const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters);
this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false});
} }
public removeKeyFilter(index: number) { removeKeyFilter(index: number) {
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).removeAt(index); this.keyFiltersFormArray.removeAt(index);
} }
public addKeyFilter() { addKeyFilter() {
const keyFiltersFormArray = this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray;
this.openKeyFilterDialog(null).subscribe((result) => { this.openKeyFilterDialog(null).subscribe((result) => {
if (result) { if (result) {
keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); this.keyFiltersFormArray.push(this.fb.control(result, [Validators.required]));
} }
}); });
} }
public editKeyFilter(index: number) { editKeyFilter(index: number) {
const keyFilter: KeyFilterInfo = const keyFilter = this.keyFiltersFormArray.at(index).value;
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).value;
this.openKeyFilterDialog(keyFilter).subscribe( this.openKeyFilterDialog(keyFilter).subscribe(
(result) => { (result) => {
if (result) { if (result) {
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).patchValue(result); this.keyFiltersFormArray.at(index).patchValue(result);
} }
} }
); );
@ -204,12 +198,8 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
} }
private updateModel() { private updateModel() {
const keyFilters: Array<KeyFilterInfo> = this.keyFilterListFormGroup.getRawValue().keyFilters; const keyFilters = this.keyFiltersFormArray.getRawValue();
if (keyFilters.length) { this.propagateChange(keyFilters.length ? keyFilters : null);
this.propagateChange(keyFilters);
} else {
this.propagateChange(null);
}
const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters);
this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false});
} }

81
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html

@ -15,70 +15,61 @@
limitations under the License. limitations under the License.
--> -->
<form [formGroup]="userFilterFormGroup" (ngSubmit)="save()" style="width: 400px;" class="user-filter-dialog"> <form (ngSubmit)="save()" style="width: 400px;" class="user-filter-dialog">
<mat-toolbar color="primary"> <mat-toolbar color="primary" class="justify-between">
<h2>{{ filter.filter | customTranslate }}</h2> <h2>{{ filter.filter | customTranslate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button <button mat-icon-button
(click)="cancel()" (click)="cancel()"
type="button"> type="button">
<mat-icon class="material-icons">close</mat-icon> <mat-icon class="material-icons">close</mat-icon>
</button> </button>
</mat-toolbar> </mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
<div mat-dialog-content> <div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async"> @for (userInputControl of userInputsFormArray.controls; track userInputControl) {
@for (userInputControl of userInputsFormArray().controls; track userInputControl) { @switch (userInputControl.controls.valueType.value) {
<div formArrayName="userInputs" @case (valueTypeEnum.STRING) {
> <mat-form-field class="mat-block" appearance="outline">
<div> <mat-label>{{ userInputControl.controls.label.value | customTranslate }}</mat-label>
@switch (userInputControl.get('valueType').value) { <input matInput [formControl]="userInputControl.controls.value">
@case (valueTypeEnum.STRING) { </mat-form-field>
<mat-form-field class="mat-block" appearance="outline"> }
<mat-label>{{ userInputControl.get('label').value | customTranslate }}</mat-label> @case (valueTypeEnum.NUMERIC) {
<input matInput [formControl]="userInputControl.get('value')"> <mat-form-field class="mat-block numeric-field" appearance="outline">
</mat-form-field> <mat-label>{{ userInputControl.controls.label.value | customTranslate }}</mat-label>
} <input required type="number" matInput [formControl]="userInputControl.controls.value">
@case (valueTypeEnum.NUMERIC) { @if (userInputControl.controls.unitSymbol.value) {
<mat-form-field class="mat-block" appearance="outline"> <div matSuffix class="mr-2">
<mat-label>{{ userInputControl.get('label').value | customTranslate }}</mat-label> {{ userInputControl.controls.unitSymbol.value }}
<input required type="number" matInput [formControl]="userInputControl.get('value')"> </div>
<div matSuffix class="mr-2"
[class.!hidden]="!userInputControl.get('unitSymbol').value">
{{ userInputControl.get('unitSymbol').value }}
</div>
</mat-form-field>
}
@case (valueTypeEnum.DATE_TIME) {
<label class="tb-title no-padding tb-required">{{ userInputControl.get('label').value | customTranslate }}</label>
<tb-datetime [formControl]="userInputControl.get('value')"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox labelPosition="before" [formControl]="userInputControl.get('value')">
{{ userInputControl.get('label').value | customTranslate }}
</mat-checkbox>
}
} }
</div> </mat-form-field>
</div> }
@case (valueTypeEnum.DATE_TIME) {
<tb-datetime [formControl]="userInputControl.controls.value"
[fieldClass]="'mat-block'"
[dateText]="userInputControl.controls.label.value | customTranslate"
appearance="outline"
required></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-slide-toggle [formControl]="userInputControl.controls.value">
<span class="inline-block max-w-72" tbTruncateWithTooltip>
{{ userInputControl.controls.label.value | customTranslate }}
</span>
</mat-slide-toggle>
}
} }
</fieldset> }
</div> </div>
<div mat-dialog-actions class="flex items-center justify-end"> <div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" <button mat-button color="primary"
type="button" type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial> (click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }} {{ 'action.cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
type="submit" type="submit"
[disabled]="(isLoading$ | async) || userFilterFormGroup.invalid || !userFilterFormGroup.dirty"> [disabled]="userInputsFormArray.invalid || !userInputsFormArray.dirty">
{{ 'action.update' | translate }} {{ 'action.update' | translate }}
</button> </button>
</div> </div>

6
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss

@ -14,14 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
:host ::ng-deep { :host ::ng-deep {
.user-filter-dialog { .numeric-field {
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { &.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder {
.mat-mdc-form-field-icon-suffix { .mat-mdc-form-field-icon-suffix {
place-self: center; place-self: center;
} }
} }
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix { &.mat-mdc-form-field-has-icon-suffix {
.mat-mdc-form-field-icon-suffix { .mat-mdc-form-field-icon-suffix {
place-self: baseline; place-self: baseline;
} }

68
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts

@ -14,12 +14,11 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core'; import { Component, DestroyRef, Inject } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
@ -39,29 +38,31 @@ export interface UserFilterDialogData {
filter: Filter; filter: Filter;
} }
interface UserInputForm {
label: FormControl<string>;
valueType: FormControl<EntityKeyValueType>;
unitSymbol: FormControl<string>;
value: FormControl<string | number | boolean>;
}
@Component({ @Component({
selector: 'tb-user-filter-dialog', selector: 'tb-user-filter-dialog',
templateUrl: './user-filter-dialog.component.html', templateUrl: './user-filter-dialog.component.html',
providers: [{ provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent }],
styleUrls: ['./user-filter-dialog.component.scss'], styleUrls: ['./user-filter-dialog.component.scss'],
standalone: false standalone: false
}) })
export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter> export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter> {
implements ErrorStateMatcher {
filter: Filter; filter: Filter;
userFilterFormGroup: FormGroup; userInputsFormArray: FormArray<FormGroup<UserInputForm>>;
valueTypeEnum = EntityKeyValueType; valueTypeEnum = EntityKeyValueType;
submitted = false;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData, @Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, protected dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
public dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
private fb: FormBuilder, private fb: FormBuilder,
private translate: TranslateService, private translate: TranslateService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
@ -70,19 +71,13 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
this.filter = data.filter; this.filter = data.filter;
const userInputs = filterToUserFilterInfoList(this.filter, this.translate); const userInputs = filterToUserFilterInfoList(this.filter, this.translate);
const userInputControls: Array<FormGroup> = []; const userInputControls = userInputs.map(input => this.createUserInputFormControl(input));
for (const userInput of userInputs) { this.userInputsFormArray = this.fb.array(userInputControls);
userInputControls.push(this.createUserInputFormControl(userInput));
}
this.userFilterFormGroup = this.fb.group({
userInputs: this.fb.array(userInputControls)
});
} }
private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup<UserInputForm> {
const predicateValue: FilterPredicateValue<string | number | boolean> = (userInput.info.keyFilterPredicate as any).value; const predicateValue: FilterPredicateValue<string | number | boolean> = (userInput.info.keyFilterPredicate as any).value;
let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; let value: string | number | boolean = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue;
let unitSymbol = ''; let unitSymbol = '';
let valueConvertor: TbUnitConverter; let valueConvertor: TbUnitConverter;
if (userInput.valueType === EntityKeyValueType.NUMERIC) { if (userInput.valueType === EntityKeyValueType.NUMERIC) {
@ -91,42 +86,31 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
value = this.unitService.convertUnitValue(value as number, userInput.unit); value = this.unitService.convertUnitValue(value as number, userInput.unit);
valueConvertor = this.unitService.geUnitConverter(unitSymbol, sourceUnit); valueConvertor = this.unitService.geUnitConverter(unitSymbol, sourceUnit);
} }
const userInputControl = this.fb.group({ const userInputControl = this.fb.group<UserInputForm>({
label: [userInput.label], label: this.fb.control(userInput.label),
valueType: [userInput.valueType], valueType: this.fb.control(userInput.valueType),
unitSymbol: [unitSymbol], unitSymbol: this.fb.control(unitSymbol),
value: [value, value: this.fb.control(value,
userInput.valueType === EntityKeyValueType.NUMERIC || userInput.valueType === EntityKeyValueType.NUMERIC ||
userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []] userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : [])
}); });
userInputControl.get('value').valueChanges.pipe( userInputControl.controls.value.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(userValue => { ).subscribe(userValue => {
let value = userValue; let val = userValue;
if (valueConvertor) { if (valueConvertor) {
value = valueConvertor(value as number); val = valueConvertor(val as number);
} }
(userInput.info.keyFilterPredicate as any).value.userValue = value; (userInput.info.keyFilterPredicate as any).value.userValue = val;
}); });
return userInputControl; return userInputControl;
} }
userInputsFormArray(): FormArray {
return this.userFilterFormGroup.get('userInputs') as FormArray;
}
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void { cancel(): void {
this.dialogRef.close(null); this.dialogRef.close(null);
} }
save(): void { save(): void {
this.submitted = true;
this.dialogRef.close(this.filter); this.dialogRef.close(this.filter);
} }
} }

2
ui-ngx/src/app/shared/components/time/datetime.component.ts

@ -40,7 +40,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor {
return this.requiredValue; return this.requiredValue;
} }
@Input() @Input()
set required(value: boolean) { set required(value: unknown) {
this.requiredValue = coerceBooleanProperty(value); this.requiredValue = coerceBooleanProperty(value);
} }

18
ui-ngx/src/app/shared/models/query/query.models.ts

@ -401,20 +401,21 @@ export interface FilterInfo {
filter: string; filter: string;
editable: boolean; editable: boolean;
keyFilters: Array<KeyFilterInfo>; keyFilters: Array<KeyFilterInfo>;
keyFiltersOperation?: ComplexOperation;
} }
export interface FiltersInfo { export interface FiltersInfo {
datasourceFilters: {[datasourceIndex: number]: FilterInfo}; datasourceFilters: {[datasourceIndex: number]: FilterInfo};
} }
export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array<KeyFilter>): string { export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array<KeyFilter>,
operation: ComplexOperation = ComplexOperation.AND): string {
const filtersText = keyFilters.map(keyFilter => const filtersText = keyFilters.map(keyFilter =>
keyFilterToText(translate, datePipe, keyFilter, keyFilterToText(translate, datePipe, keyFilter, operation));
keyFilters.length > 1 ? ComplexOperation.AND : undefined));
let result: string; let result: string;
if (filtersText.length > 1) { if (filtersText.length > 1) {
const andText = translate.instant('filter.operation.and'); const opText = translate.instant(complexOperationTranslationMap.get(operation));
result = filtersText.join(' <span class="tb-filter-complex-operation">' + andText + '</span> '); result = filtersText.join(' <span class="tb-filter-complex-operation">' + opText + '</span> ');
} else { } else {
result = filtersText[0]; result = filtersText[0];
} }
@ -802,6 +803,7 @@ export const singleEntityFilterFromDeviceId = (deviceId: string): EntityFilter =
export interface EntityCountQuery { export interface EntityCountQuery {
entityFilter: EntityFilter; entityFilter: EntityFilter;
keyFilters?: Array<KeyFilter>; keyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
} }
export interface AbstractDataQuery<T extends EntityDataPageLink> extends EntityCountQuery { export interface AbstractDataQuery<T extends EntityDataPageLink> extends EntityCountQuery {
@ -940,13 +942,15 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string =>
const newFilterName = createFilterName(filters, filterInfo.filter); const newFilterName = createFilterName(filters, filterInfo.filter);
newFilterId = guid(); newFilterId = guid();
filters[newFilterId] = {id: newFilterId, filter: newFilterName, filters[newFilterId] = {id: newFilterId, filter: newFilterName,
keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; keyFilters: filterInfo.keyFilters, editable: filterInfo.editable,
keyFiltersOperation: filterInfo.keyFiltersOperation};
} }
return newFilterId; return newFilterId;
} }
const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => {
return isEqual(filter1.keyFilters, filter2.keyFilters); return isEqual(filter1.keyFilters, filter2.keyFilters) &&
filter1.keyFiltersOperation === filter2.keyFiltersOperation;
} }
const createFilterName = (filters: Filters, filter: string): string => { const createFilterName = (filters: Filters, filter: string): string => {

2
ui-ngx/src/app/shared/models/widget.models.ts

@ -24,6 +24,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { import {
AlarmFilter, AlarmFilter,
AlarmFilterConfig, AlarmFilterConfig,
ComplexOperation,
EntityDataPageLink, EntityDataPageLink,
EntityFilter, EntityFilter,
KeyFilter KeyFilter
@ -450,6 +451,7 @@ export interface Datasource {
origDatasourceIndex?: number; origDatasourceIndex?: number;
pageLink?: EntityDataPageLink; pageLink?: EntityDataPageLink;
keyFilters?: Array<KeyFilter>; keyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
entityFilter?: EntityFilter; entityFilter?: EntityFilter;
alarmFilterConfig?: AlarmFilterConfig; alarmFilterConfig?: AlarmFilterConfig;
alarmFilter?: AlarmFilter; alarmFilter?: AlarmFilter;

1
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3610,6 +3610,7 @@
"order-priority": "Display order", "order-priority": "Display order",
"key-filter": "Key filter", "key-filter": "Key filter",
"key-filters": "Key filters", "key-filters": "Key filters",
"or-conditions-disabled": "OR conditions between key filters are disabled by the system administrator.",
"key-name": "Key name", "key-name": "Key name",
"key-name-required": "Key name is required.", "key-name-required": "Key name is required.",
"key-type": { "key-type": {

Loading…
Cancel
Save