Browse Source

Merge branch 'AD/rule-node/kafka-remove-serializer' of https://github.com/ArtemDzhereleiko/thingsboard into fix/prod-5664

pull/12774/head
YevhenBondarenko 1 year ago
parent
commit
1d2fe8dbaa
  1. 3
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  2. 9
      application/src/main/java/org/thingsboard/server/controller/TbResourceController.java
  3. 11
      application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java
  4. 5
      application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java
  5. 76
      application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java
  6. 74
      application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
  7. 202
      application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java
  8. 221
      application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
  9. 5
      common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java
  10. 32
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java
  11. 38
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbDate.java
  12. 60
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java
  13. 29
      dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java
  14. 3
      dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardInfoDao.java
  15. 12
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java
  16. 60
      dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java
  17. 15
      dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java
  18. 20
      dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java
  19. 16
      dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java
  20. 11
      dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java
  21. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java
  22. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java
  23. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java
  24. 5
      dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java
  25. 3
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  26. 4
      ui-ngx/src/app/core/http/resource.service.ts
  27. 14
      ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html
  28. 2
      ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts
  29. 191
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts
  30. 130
      ui-ngx/src/app/modules/home/pages/admin/resource/resources-datasource.ts
  31. 68
      ui-ngx/src/app/shared/components/image/image-gallery.component.ts
  32. 10
      ui-ngx/src/app/shared/components/image/image-references.component.ts
  33. 42
      ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html
  34. 2
      ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.scss
  35. 61
      ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts
  36. 24
      ui-ngx/src/app/shared/models/resource.models.ts
  37. 6
      ui-ngx/src/app/shared/shared.module.ts
  38. 9
      ui-ngx/src/assets/locale/locale.constant-en_US.json

3
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -124,7 +124,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import static org.thingsboard.server.common.data.DataConstants.MAIN_QUEUE_NAME;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_DELETED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ATTRIBUTES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.ENTITY_CREATED;
@ -196,7 +195,7 @@ public class DefaultTbContext implements TbContext {
@Override
public void enqueue(TbMsg tbMsg, Runnable onSuccess, Consumer<Throwable> onFailure) {
enqueue(tbMsg, MAIN_QUEUE_NAME, onSuccess, onFailure);
enqueue(tbMsg, tbMsg.getQueueName(), onSuccess, onFailure);
}
@Override

9
application/src/main/java/org/thingsboard/server/controller/TbResourceController.java

@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@ -322,12 +323,14 @@ public class TbResourceController extends BaseController {
notes = "Deletes the Resource. Referencing non-existing Resource Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@DeleteMapping(value = "/resource/{resourceId}")
public void deleteResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION)
@PathVariable("resourceId") String strResourceId) throws ThingsboardException {
public ResponseEntity<TbResourceDeleteResult> deleteResource(@Parameter(description = RESOURCE_ID_PARAM_DESCRIPTION)
@PathVariable("resourceId") String strResourceId,
@RequestParam(name = "force", required = false) boolean force) throws ThingsboardException {
checkParameter(RESOURCE_ID, strResourceId);
TbResourceId resourceId = new TbResourceId(toUUID(strResourceId));
TbResource tbResource = checkResourceId(resourceId, Operation.DELETE);
tbResourceService.delete(tbResource, getCurrentUser());
TbResourceDeleteResult tbResourceDeleteResult = tbResourceService.delete(tbResource, force, getCurrentUser());
return (tbResourceDeleteResult.isSuccess() ? ResponseEntity.ok() : ResponseEntity.badRequest()).body(tbResourceDeleteResult);
}
private ResponseEntity<ByteArrayResource> downloadResourceIfChanged(String strResourceId, String etag) throws ThingsboardException {

11
application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
@ -89,7 +90,7 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements
}
@Override
public void delete(TbResource tbResource, User user) {
public TbResourceDeleteResult delete(TbResourceInfo tbResource, boolean force, User user) {
if (tbResource.getResourceType() == ResourceType.IMAGE) {
throw new IllegalArgumentException("Image resource type is not supported");
}
@ -97,8 +98,12 @@ public class DefaultTbResourceService extends AbstractTbEntityService implements
TbResourceId resourceId = tbResource.getId();
TenantId tenantId = tbResource.getTenantId();
try {
resourceService.deleteResource(tenantId, resourceId);
logEntityActionService.logEntityAction(tenantId, resourceId, tbResource, actionType, user, resourceId.toString());
TbResourceDeleteResult result = resourceService.deleteResource(tenantId, resourceId, force);
if (result.isSuccess()) {
logEntityActionService.logEntityAction(tenantId, resourceId, tbResource, actionType, user, resourceId.toString());
}
return result;
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, emptyId(EntityType.TB_RESOURCE),
actionType, user, e, resourceId.toString());

5
application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java

@ -18,8 +18,9 @@ package org.thingsboard.server.service.resource;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.lwm2m.LwM2mObject;
@ -37,7 +38,7 @@ public interface TbResourceService {
TbResourceInfo save(TbResource entity, SecurityUser user) throws Exception;
void delete(TbResource entity, User user);
TbResourceDeleteResult delete(TbResourceInfo entity, boolean force, User user);
List<LwM2mObject> findLwM2mObject(TenantId tenantId,
String sortOrder,

76
application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java

@ -131,11 +131,87 @@ class DefaultTbContextTest {
defaultTbContext.input(msg, ruleChainId);
// THEN
then(clusterService).should().pushMsgToRuleEngine(eq(tpi), eq(msg.getId()), any(), any());
}
@MethodSource
@ParameterizedTest
public void givenMsgWithQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi(String queueName) {
// GIVEN
var tpi = resolve(queueName);
given(mainCtxMock.resolve(eq(ServiceType.TB_RULE_ENGINE), eq(queueName), eq(TENANT_ID), eq(TENANT_ID))).willReturn(tpi);
var clusterService = mock(TbClusterService.class);
given(mainCtxMock.getClusterService()).willReturn(clusterService);
var callbackMock = mock(TbMsgCallback.class);
given(callbackMock.isMsgValid()).willReturn(true);
var ruleNode = new RuleNode(RULE_NODE_ID);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(TENANT_ID)
.queueName(queueName)
.copyMetaData(TbMsgMetaData.EMPTY)
.data(TbMsg.EMPTY_STRING)
.callback(callbackMock)
.build();
ruleNode.setRuleChainId(RULE_CHAIN_ID);
ruleNode.setDebugSettings(DebugSettings.failures());
given(nodeCtxMock.getTenantId()).willReturn(TENANT_ID);
// WHEN
defaultTbContext.enqueue(msg, () -> {}, t -> {});
// THEN
then(clusterService).should().pushMsgToRuleEngine(eq(tpi), eq(msg.getId()), any(), any());
}
@MethodSource
@ParameterizedTest
public void givenMsgAndQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi(String queueName) {
// GIVEN
var tpi = resolve(queueName);
given(mainCtxMock.resolve(eq(ServiceType.TB_RULE_ENGINE), eq(queueName), eq(TENANT_ID), eq(TENANT_ID))).willReturn(tpi);
var clusterService = mock(TbClusterService.class);
given(mainCtxMock.getClusterService()).willReturn(clusterService);
var callbackMock = mock(TbMsgCallback.class);
given(callbackMock.isMsgValid()).willReturn(true);
var ruleNode = new RuleNode(RULE_NODE_ID);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(TENANT_ID)
.copyMetaData(TbMsgMetaData.EMPTY)
.data(TbMsg.EMPTY_STRING)
.callback(callbackMock)
.build();
ruleNode.setRuleChainId(RULE_CHAIN_ID);
ruleNode.setDebugSettings(DebugSettings.failures());
given(nodeCtxMock.getTenantId()).willReturn(TENANT_ID);
// WHEN
defaultTbContext.enqueue(msg, queueName, () -> {}, t -> {});
// THEN
then(clusterService).should().pushMsgToRuleEngine(eq(tpi), eq(msg.getId()), any(), any());
}
private static Stream<String> givenMsgWithQueueName_whenInput_thenVerifyEnqueueWithCorrectTpi() {
return testQueueNames();
}
private static Stream<String> givenMsgWithQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi() {
return testQueueNames();
}
private static Stream<String> givenMsgAndQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi() {
return testQueueNames();
}
private static Stream<String> testQueueNames() {
return Stream.of("Main", "Test", null);
}

74
application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java

@ -27,6 +27,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Dashboard;
import org.thingsboard.server.common.data.Device;
@ -60,6 +61,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.DataConstants.DEFAULT_DEVICE_TYPE;
@ -79,6 +81,8 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Autowired
private DeviceProfileDao deviceProfileDao;
static final String LWM2M_PROFILE_JSON = "{\"name\":\"lwm2m profile\",\"type\":\"DEFAULT\",\"image\":null,\"defaultQueueName\":null,\"transportType\":\"LWM2M\",\"provisionType\":\"DISABLED\",\"description\":\"\",\"profileData\":{\"configuration\":{\"type\":\"DEFAULT\"},\"transportConfiguration\":{\"observeAttr\":{\"observe\":[],\"attribute\":[],\"telemetry\":[\"/11_1.1/0/0\"],\"keyName\":{\"/11_1.1/0/0\":\"profileName\"},\"attributeLwm2m\":{}},\"bootstrap\":[{\"shortServerId\":123,\"bootstrapServerIs\":false,\"host\":\"0.0.0.0\",\"port\":5685,\"clientHoldOffTime\":1,\"serverPublicKey\":\"\",\"serverCertificate\":\"\",\"bootstrapServerAccountTimeout\":0,\"lifetime\":300,\"defaultMinPeriod\":1,\"notifIfDisabled\":true,\"binding\":\"U\",\"securityMode\":\"NO_SEC\"}],\"clientLwM2mSettings\":{\"clientOnlyObserveAfterConnect\":1,\"fwUpdateStrategy\":1,\"swUpdateStrategy\":1,\"powerMode\":\"DRX\",\"edrxCycle\":81000,\"psmActivityTimer\":10000,\"pagingTransmissionWindow\":10000,\"defaultObjectIDVer\":\"1.0\"},\"bootstrapServerUpdateEnable\":false,\"type\":\"LWM2M\"},\"alarms\":null,\"provisionConfiguration\":{\"type\":\"DISABLED\"}}}";
static class Config {
@Bean
@Primary
@ -119,7 +123,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
Mockito.reset(tbClusterService, auditLogService);
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Assert.assertNotNull(savedDeviceProfile);
Assert.assertNotNull(savedDeviceProfile.getId());
Assert.assertTrue(savedDeviceProfile.getCreatedTime() > 0);
@ -135,7 +139,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
ActionType.ADDED);
savedDeviceProfile.setName("New device profile");
doPost("/api/deviceProfile", savedDeviceProfile, DeviceProfile.class);
saveDeviceProfile(savedDeviceProfile);
DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class);
Assert.assertEquals(savedDeviceProfile.getName(), foundDeviceProfile.getName());
@ -162,7 +166,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testFindDeviceProfileById() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class);
Assert.assertNotNull(foundDeviceProfile);
Assert.assertEquals(savedDeviceProfile, foundDeviceProfile);
@ -171,7 +175,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void whenGetDeviceProfileById_thenPermissionsAreChecked() throws Exception {
DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null);
deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
deviceProfile = saveDeviceProfile(deviceProfile);
loginDifferentTenant();
@ -183,7 +187,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testFindDeviceProfileInfoById() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
DeviceProfileInfo foundDeviceProfileInfo = doGet("/api/deviceProfileInfo/" + savedDeviceProfile.getId().getId().toString(), DeviceProfileInfo.class);
Assert.assertNotNull(foundDeviceProfileInfo);
Assert.assertEquals(savedDeviceProfile.getId(), foundDeviceProfileInfo.getId());
@ -213,7 +217,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void whenGetDeviceProfileInfoById_thenPermissionsAreChecked() throws Exception {
DeviceProfile deviceProfile = createDeviceProfile("Device profile 1", null);
deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
deviceProfile = saveDeviceProfile(deviceProfile);
loginDifferentTenant();
doGet("/api/deviceProfileInfo/" + deviceProfile.getId())
@ -235,7 +239,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testSetDefaultDeviceProfile() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile 1");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Mockito.reset(tbClusterService, auditLogService);
@ -328,7 +332,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testChangeDeviceProfileTypeNull() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Mockito.reset(tbClusterService, auditLogService);
@ -345,7 +349,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testChangeDeviceProfileTransportTypeWithExistingDevices() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Device device = new Device();
device.setName("Test device");
device.setType("default");
@ -367,7 +371,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testDeleteDeviceProfileWithExistingDevice() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Device device = new Device();
device.setName("Test device");
@ -419,7 +423,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
public void testSaveDeviceProfileWithFirmwareFromDifferentTenant() throws Exception {
loginDifferentTenant();
DeviceProfile differentProfile = createDeviceProfile("Different profile");
differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class);
differentProfile = saveDeviceProfile(differentProfile);
SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest();
firmwareInfo.setDeviceProfileId(differentProfile.getId());
firmwareInfo.setType(FIRMWARE);
@ -441,7 +445,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
public void testSaveDeviceProfileWithSoftwareFromDifferentTenant() throws Exception {
loginDifferentTenant();
DeviceProfile differentProfile = createDeviceProfile("Different profile");
differentProfile = doPost("/api/deviceProfile", differentProfile, DeviceProfile.class);
differentProfile = saveDeviceProfile(differentProfile);
SaveOtaPackageInfoRequest softwareInfo = new SaveOtaPackageInfoRequest();
softwareInfo.setDeviceProfileId(differentProfile.getId());
softwareInfo.setType(SOFTWARE);
@ -462,7 +466,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testDeleteDeviceProfile() throws Exception {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile");
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Mockito.reset(tbClusterService, auditLogService);
@ -495,7 +499,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
int cntEntity = 28;
for (int i = 0; i < cntEntity; i++) {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile" + i);
deviceProfiles.add(doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class));
deviceProfiles.add(saveDeviceProfile(deviceProfile));
}
testNotifyManyEntityManyTimeMsgToEdgeServiceEntityEqAny(new DeviceProfile(), new DeviceProfile(),
@ -552,7 +556,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
for (int i = 0; i < 28; i++) {
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile" + i);
deviceProfiles.add(doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class));
deviceProfiles.add(saveDeviceProfile(deviceProfile));
}
List<DeviceProfileInfo> loadedDeviceProfileInfos = new ArrayList<>();
@ -961,7 +965,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
JsonTransportPayloadConfiguration jsonTransportPayloadConfiguration = new JsonTransportPayloadConfiguration();
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(jsonTransportPayloadConfiguration, true);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Assert.assertNotNull(savedDeviceProfile);
Assert.assertEquals(savedDeviceProfile.getTransportType(), DeviceTransportType.MQTT);
Assert.assertTrue(savedDeviceProfile.getProfileData().getTransportConfiguration() instanceof MqttDeviceProfileTransportConfiguration);
@ -979,7 +983,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
"v1/devices/me/telemetry", "v1/devices/me/attributes", "v1/devices/me/subscribeattributes");
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile",
mqttDeviceProfileTransportConfiguration);
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Assert.assertNotNull(savedDeviceProfile);
Assert.assertEquals(savedDeviceProfile.getTransportType(), DeviceTransportType.MQTT);
Assert.assertTrue(savedDeviceProfile.getProfileData().getTransportConfiguration() instanceof MqttDeviceProfileTransportConfiguration);
@ -997,7 +1001,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = this.createProtoTransportPayloadConfiguration(schema, schema, null, null);
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
DeviceProfile savedDeviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
DeviceProfile savedDeviceProfile = saveDeviceProfile(deviceProfile);
Assert.assertNotNull(savedDeviceProfile);
DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + savedDeviceProfile.getId().getId().toString(), DeviceProfile.class);
Assert.assertEquals(savedDeviceProfile, foundDeviceProfile);
@ -1036,14 +1040,14 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testDeleteDeviceProfileWithDeleteRelationsOk() throws Exception {
DeviceProfileId deviceProfileId = savedDeviceProfile("DeviceProfile for Test WithRelationsOk").getId();
DeviceProfileId deviceProfileId = saveDeviceProfile("DeviceProfile for Test WithRelationsOk").getId();
testEntityDaoWithRelationsOk(savedTenant.getId(), deviceProfileId, "/api/deviceProfile/" + deviceProfileId);
}
@Ignore
@Test
public void testDeleteDeviceProfileExceptionWithRelationsTransactional() throws Exception {
DeviceProfileId deviceProfileId = savedDeviceProfile("DeviceProfile for Test WithRelations Transactional Exception").getId();
DeviceProfileId deviceProfileId = saveDeviceProfile("DeviceProfile for Test WithRelations Transactional Exception").getId();
testEntityDaoWithRelationsTransactionalException(deviceProfileDao, savedTenant.getId(), deviceProfileId, "/api/deviceProfile/" + deviceProfileId);
}
@ -1103,8 +1107,36 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
Assert.assertEquals(count, deviceProfileNames.size());
}
private DeviceProfile savedDeviceProfile(String name) {
@Test
public void testSaveDeviceProfileWithOutdatedVersion() throws Exception {
DeviceProfile deviceProfile = JacksonUtil.fromString(LWM2M_PROFILE_JSON, DeviceProfile.class);
deviceProfile.setName("Device profile v1.0");
deviceProfile = saveDeviceProfile(deviceProfile);
assertThat(deviceProfile.getVersion()).isOne();
deviceProfile.setName("Device profile v2.0");
deviceProfile = saveDeviceProfile(deviceProfile);
assertThat(deviceProfile.getVersion()).isEqualTo(2);
deviceProfile.setName("Device profile v1.1");
deviceProfile.setVersion(1L);
String response = doPost("/api/deviceProfile", deviceProfile).andExpect(status().isConflict())
.andReturn().getResponse().getContentAsString();
assertThat(JacksonUtil.toJsonNode(response).get("message").asText())
.containsIgnoringCase("already changed by someone else");
deviceProfile.setVersion(null); // overriding entity
deviceProfile = saveDeviceProfile(deviceProfile);
assertThat(deviceProfile.getName()).isEqualTo("Device profile v1.1");
assertThat(deviceProfile.getVersion()).isEqualTo(3);
}
private DeviceProfile saveDeviceProfile(String name) {
DeviceProfile deviceProfile = createDeviceProfile(name);
return saveDeviceProfile(deviceProfile);
}
private DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile) {
return doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
}

202
application/src/test/java/org/thingsboard/server/controller/TbResourceControllerTest.java

File diff suppressed because one or more lines are too long

221
application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java

File diff suppressed because one or more lines are too long

5
common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java

@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.id.TbResourceId;
@ -69,9 +70,7 @@ public interface ResourceService extends EntityDaoService {
PageData<TbResource> findTenantResourcesByResourceTypeAndPageLink(TenantId tenantId, ResourceType lwm2mModel, PageLink pageLink);
void deleteResource(TenantId tenantId, TbResourceId resourceId);
void deleteResource(TenantId tenantId, TbResourceId resourceId, boolean force);
TbResourceDeleteResult deleteResource(TenantId tenantId, TbResourceId resourceId, boolean force);
void deleteResourcesByTenantId(TenantId tenantId);

32
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2025 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.
*/
package org.thingsboard.server.common.data;
import lombok.Builder;
import lombok.Data;
import org.thingsboard.server.common.data.id.HasId;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class TbResourceDeleteResult {
private boolean success;
private Map<String, List<? extends HasId<?>>> references;
}

38
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbDate.java

@ -37,6 +37,7 @@ import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAccessor;
import java.util.Locale;
import java.util.function.BiFunction;
import java.util.function.Function;
public class TbDate implements Serializable, Cloneable {
@ -481,6 +482,43 @@ public class TbDate implements Serializable, Cloneable {
instant = Instant.ofEpochMilli(dateMilliSecond);
}
public void addDays(int days) {
adjustTime(zonedDateTime -> zonedDateTime.plusDays(days));
}
public void addYears(int years) {
adjustTime(zonedDateTime -> zonedDateTime.plusYears(years));
}
public void addMonths(int months) {
adjustTime(zonedDateTime -> zonedDateTime.plusMonths(months));
}
public void addWeeks(int weeks) {
adjustTime(zonedDateTime -> zonedDateTime.plusWeeks(weeks));
}
public void addHours(int hours) {
adjustTime(zonedDateTime -> zonedDateTime.plusHours(hours));
}
public void addMinutes(int minutes) {
adjustTime(zonedDateTime -> zonedDateTime.plusMinutes(minutes));
}
public void addSeconds(int seconds) {
adjustTime(zonedDateTime -> zonedDateTime.plusSeconds(seconds));
}
public void addNanos(long nanos) {
adjustTime(zonedDateTime -> zonedDateTime.plusNanos(nanos));
}
private void adjustTime(Function<ZonedDateTime, ZonedDateTime> adjuster) {
ZonedDateTime zonedDateTime = adjuster.apply(getZonedDateTime());
this.instant = zonedDateTime.toInstant();
}
public ZoneOffset getLocaleZoneOffset(Instant... instants){
return ZoneId.systemDefault().getRules().getOffset(instants.length > 0 ? instants[0] : this.instant);
}

60
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java

@ -885,4 +885,64 @@ class TbDateTest {
Assertions.assertNotNull(serializedTbDate);
Assertions.assertEquals(expectedDate.toString(), serializedTbDate);
}
@Test
void testAddFunctions() {
TbDate d = new TbDate(2024, 1, 1, 10, 0, 0, 0);
testResultChangeDateTime(d);
d.addYears(1);
testResultChangeDateTime(d);
d.addYears(-2);
testResultChangeDateTime(d);
d.addMonths(2);
testResultChangeDateTime(d);
d.addMonths(10);
testResultChangeDateTime(d);
d.addMonths(-13);
testResultChangeDateTime(d);
d.addWeeks(4);
testResultChangeDateTime(d);
d.addWeeks(-5);
testResultChangeDateTime(d);
d.addDays(6);
testResultChangeDateTime(d);
d.addDays(45);
testResultChangeDateTime(d);
d.addDays(-50);
testResultChangeDateTime(d);
d.addHours(23);
testResultChangeDateTime(d);
d.addHours(-47);
testResultChangeDateTime(d);
d.addMinutes(59);
testResultChangeDateTime(d);
d.addMinutes(-60);
testResultChangeDateTime(d);
d.addSeconds(59);
testResultChangeDateTime(d);
d.addSeconds(-60);
testResultChangeDateTime(d);
d.addNanos(999999);
testResultChangeDateTime(d);
d.addNanos(-1000000);
testResultChangeDateTime(d);
}
}

29
dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 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.
*/
package org.thingsboard.server.dao;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.List;
public interface ResourceContainerDao<T extends HasId<?>> {
List<T> findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit);
List<T> findByResourceLink(String link, int limit);
}

3
dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardInfoDao.java

@ -20,13 +20,14 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ImageContainerDao;
import org.thingsboard.server.dao.ResourceContainerDao;
import java.util.UUID;
/**
* The Interface DashboardInfoDao.
*/
public interface DashboardInfoDao extends Dao<DashboardInfo>, ImageContainerDao<DashboardInfo> {
public interface DashboardInfoDao extends Dao<DashboardInfo>, ImageContainerDao<DashboardInfo>, ResourceContainerDao<DashboardInfo> {
/**
* Find dashboards by tenantId and page link.

12
dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java

@ -75,8 +75,6 @@ import static org.thingsboard.server.common.data.StringUtils.isNotEmpty;
@Slf4j
public class BaseImageService extends BaseResourceService implements ImageService {
private static final int MAX_ENTITIES_TO_FIND = 10;
public static Map<String, String> DASHBOARD_BASE64_MAPPING = new HashMap<>();
public static Map<String, String> WIDGET_TYPE_BASE64_MAPPING = new HashMap<>();
@ -107,19 +105,15 @@ public class BaseImageService extends BaseResourceService implements ImageServic
private final AssetProfileDao assetProfileDao;
private final DeviceProfileDao deviceProfileDao;
private final WidgetsBundleDao widgetsBundleDao;
private final WidgetTypeDao widgetTypeDao;
private final DashboardInfoDao dashboardInfoDao;
private final Map<EntityType, ImageContainerDao<?>> imageContainerDaoMap = new HashMap<>();
public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator,
AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao,
WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) {
super(resourceDao, resourceInfoDao, resourceValidator);
super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao);
this.assetProfileDao = assetProfileDao;
this.deviceProfileDao = deviceProfileDao;
this.widgetsBundleDao = widgetsBundleDao;
this.widgetTypeDao = widgetTypeDao;
this.dashboardInfoDao = dashboardInfoDao;
}
@PostConstruct
@ -131,7 +125,6 @@ public class BaseImageService extends BaseResourceService implements ImageServic
imageContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
}
@Override
@SneakyThrows
public TbResourceInfo saveImage(TbResource image) {
@ -311,7 +304,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic
}
}
if (success) {
deleteResource(tenantId, imageId, force);
success = deleteResource(tenantId, imageId, true)
.isSuccess();
}
return result.success(success).build();
}

60
dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java

@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.google.common.hash.Hashing;
import com.google.common.util.concurrent.ListenableFuture;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.ResourceExportData;
import org.thingsboard.server.common.data.ResourceSubType;
import org.thingsboard.server.common.data.ResourceType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDeleteResult;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.TbResourceInfoFilter;
import org.thingsboard.server.common.data.id.EntityId;
@ -48,6 +50,8 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.ResourceContainerDao;
import org.thingsboard.server.dao.dashboard.DashboardInfoDao;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
@ -55,6 +59,7 @@ import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.service.validator.ResourceDataValidator;
import org.thingsboard.server.dao.widget.WidgetTypeDao;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@ -85,6 +90,17 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
protected final TbResourceDao resourceDao;
protected final TbResourceInfoDao resourceInfoDao;
protected final ResourceDataValidator resourceValidator;
protected final WidgetTypeDao widgetTypeDao;
protected final DashboardInfoDao dashboardInfoDao;
private final Map<EntityType, ResourceContainerDao<?>> resourceContainerDaoMap = new HashMap<>();
protected static final int MAX_ENTITIES_TO_FIND = 10;
@PostConstruct
public void init() {
resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao);
resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao);
}
@Autowired @Lazy
private ImageService imageService;
@ -313,23 +329,45 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
}
@Override
public void deleteResource(TenantId tenantId, TbResourceId resourceId) {
deleteResource(tenantId, resourceId, false);
}
@Override
public void deleteResource(TenantId tenantId, TbResourceId resourceId, boolean force) {
public TbResourceDeleteResult deleteResource(TenantId tenantId, TbResourceId resourceId, boolean force) {
log.trace("Executing deleteResource [{}] [{}]", tenantId, resourceId);
Validator.validateId(resourceId, id -> INCORRECT_RESOURCE_ID + id);
TbResourceInfo resource = findResourceInfoById(tenantId, resourceId);
boolean success = true;
var result = TbResourceDeleteResult.builder();
if (resource == null) {
return;
if (!force) {
success = false;
}
return result.success(success).build();
}
if (!force) {
resourceValidator.validateDelete(tenantId, resource);
if (resource.getResourceType() == ResourceType.JS_MODULE) {
var link = resource.getLink();
Map<String, List<? extends HasId<?>>> affectedEntities = new HashMap<>();
resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> {
var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) :
resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND);
if (!entities.isEmpty()) {
affectedEntities.put(entityType.name(), entities);
}
});
if (!affectedEntities.isEmpty()) {
success = false;
result.references(affectedEntities);
}
}
}
resourceDao.removeById(tenantId, resourceId.getId());
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build());
if (success) {
resourceDao.removeById(tenantId, resourceId.getId());
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build());
}
return result.success(success).build();
}
@Override
@ -666,7 +704,7 @@ public class BaseResourceService extends AbstractCachedEntityService<ResourceInf
@Override
protected void removeEntity(TenantId tenantId, TbResourceId resourceId) {
deleteResource(tenantId, resourceId);
deleteResource(tenantId, resourceId, true);
}
};

15
dao/src/main/java/org/thingsboard/server/dao/service/validator/ResourceDataValidator.java

@ -21,7 +21,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.id.TbResourceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
@ -30,9 +29,6 @@ import org.thingsboard.server.dao.resource.TbResourceDao;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.widget.WidgetTypeDao;
import java.util.List;
import static org.thingsboard.server.common.data.EntityType.TB_RESOURCE;
@ -42,9 +38,6 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
@Autowired
private TbResourceDao resourceDao;
@Autowired
private WidgetTypeDao widgetTypeDao;
@Autowired
private TenantService tenantService;
@ -111,12 +104,4 @@ public class ResourceDataValidator extends DataValidator<TbResource> {
validateMaxSumDataSizePerTenant(tenantId, resourceDao, maxSumResourcesDataInBytes, dataSize, TB_RESOURCE);
}
}
public void validateDelete(TenantId tenantId, TbResourceInfo resourceInfo) {
List<String> widgets = widgetTypeDao.findWidgetTypesNamesByTenantIdAndResourceLink(tenantId.getId(), resourceInfo.getLink());
if (!widgets.isEmpty()) {
throw new DataValidationException("Following widget types use this resource: " + String.join(", ", widgets));
}
}
}

20
dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java

@ -88,10 +88,10 @@ public abstract class JpaAbstractDao<E extends BaseEntity<D>, D>
boolean flushed = false;
EntityManager entityManager = getEntityManager();
if (isNew) {
entityManager.persist(entity);
if (entity instanceof HasVersion versionedEntity) {
versionedEntity.setVersion(1L);
}
entityManager.persist(entity);
} else {
if (entity instanceof HasVersion versionedEntity) {
if (versionedEntity.getVersion() == null) {
@ -106,23 +106,25 @@ public abstract class JpaAbstractDao<E extends BaseEntity<D>, D>
}
}
versionedEntity = entityManager.merge(versionedEntity);
entity = (E) versionedEntity;
/*
* by default, Hibernate doesn't issue an update query and thus version increment
* if the entity was not modified. to bypass this and always increment the version, we do it manually
* */
versionedEntity.setVersion(versionedEntity.getVersion() + 1);
/*
* flushing and then removing the entity from the persistence context so that it is not affected
* by next flushes (e.g. when a transaction is committed) to avoid double version increment
* */
entityManager.flush();
entityManager.detach(versionedEntity);
flushed = true;
entity = (E) versionedEntity;
} else {
entity = entityManager.merge(entity);
}
}
if (entity instanceof HasVersion versionedEntity) {
/*
* flushing and then removing the entity from the persistence context so that it is not affected
* by next flushes (e.g. when a transaction is committed) to avoid double version increment
* */
entityManager.flush();
entityManager.detach(versionedEntity);
flushed = true;
}
if (flush && !flushed) {
entityManager.flush();
}

16
dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java

@ -78,13 +78,21 @@ public interface DashboardInfoRepository extends JpaRepository<DashboardInfoEnti
@Query(nativeQuery = true,
value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId " +
"and (d.image = :imageLink or d.configuration ILIKE CONCAT('%\"', :imageLink, '\"%')) limit :lmt"
"and (d.image = :imageLink or d.configuration ILIKE CONCAT('%\"', :imageLink, '\"%')) limit :limit"
)
List<DashboardInfoEntity> findByTenantAndImageLink(@Param("tenantId") UUID tenantId, @Param("imageLink") String imageLink, @Param("lmt") int lmt);
List<DashboardInfoEntity> findByTenantAndImageLink(@Param("tenantId") UUID tenantId, @Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(nativeQuery = true,
value = "SELECT * FROM dashboard d WHERE d.image = :imageLink or d.configuration ILIKE CONCAT('%\"', :imageLink, '\"%') limit :lmt"
value = "SELECT * FROM dashboard d WHERE d.image = :imageLink or d.configuration ILIKE CONCAT('%\"', :imageLink, '\"%') limit :limit"
)
List<DashboardInfoEntity> findByImageLink(@Param("imageLink") String imageLink, @Param("lmt") int lmt);
List<DashboardInfoEntity> findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
nativeQuery = true)
List<DashboardInfoEntity> findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
@Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit",
nativeQuery = true)
List<DashboardInfoEntity> findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
}

11
dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java

@ -133,4 +133,15 @@ public class JpaDashboardInfoDao extends JpaAbstractDao<DashboardInfoEntity, Das
public List<DashboardInfo> findByImageLink(String imageLink, int limit) {
return DaoUtil.convertDataList(dashboardInfoRepository.findByImageLink(imageLink, limit));
}
@Override
public List<DashboardInfo> findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) {
return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit));
}
@Override
public List<DashboardInfo> findByResourceLink(String link, int limit) {
return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit));
}
}

15
dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java

@ -190,11 +190,6 @@ public class JpaWidgetTypeDao extends JpaAbstractDao<WidgetTypeDetailsEntity, Wi
return DaoUtil.getData(widgetTypeRepository.findByTenantIdAndFqn(tenantId, fqn));
}
@Override
public List<String> findWidgetTypesNamesByTenantIdAndResourceLink(UUID tenantId, String link) {
return widgetTypeRepository.findNamesByTenantIdAndResourceLink(tenantId, link);
}
@Override
public List<WidgetTypeId> findWidgetTypeIdsByTenantIdAndFqns(UUID tenantId, List<String> widgetFqns) {
var idFqnPairs = widgetTypeRepository.findWidgetTypeIdsByTenantIdAndFqns(tenantId, widgetFqns);
@ -266,4 +261,14 @@ public class JpaWidgetTypeDao extends JpaAbstractDao<WidgetTypeDetailsEntity, Wi
}
@Override
public List<WidgetTypeInfo> findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) {
return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit));
}
@Override
public List<WidgetTypeInfo> findByResourceLink(String link, int limit) {
return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit));
}
}

15
dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java

@ -204,13 +204,20 @@ public interface WidgetTypeInfoRepository extends JpaRepository<WidgetTypeInfoEn
@Query(nativeQuery = true,
value = "SELECT * FROM widget_type_info_view wti WHERE wti.id IN " +
"(select id from widget_type where tenant_id = :tenantId " +
"and (image = :imageLink or descriptor ILIKE CONCAT('%\"', :imageLink, '\"%')) limit :lmt)"
"and (image = :imageLink or descriptor ILIKE CONCAT('%\"', :imageLink, '\"%')) limit :limit)"
)
List<WidgetTypeInfoEntity> findByTenantAndImageUrl(@Param("tenantId") UUID tenantId, @Param("imageLink") String imageLink, @Param("lmt") int lmt);
List<WidgetTypeInfoEntity> findByTenantAndImageUrl(@Param("tenantId") UUID tenantId, @Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(nativeQuery = true,
value = "SELECT * FROM widget_type_info_view wti WHERE wti.id IN " +
"(select id from widget_type where image = :imageLink or descriptor ILIKE CONCAT('%', :imageLink, '%') limit :lmt)"
"(select id from widget_type where image = :imageLink or descriptor ILIKE CONCAT('%', :imageLink, '%') limit :limit)"
)
List<WidgetTypeInfoEntity> findByImageUrl(@Param("imageLink") String imageLink, @Param("lmt") int lmt);
List<WidgetTypeInfoEntity> findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit);
@Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
List<WidgetTypeInfoEntity> findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit);
@Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true)
List<WidgetTypeInfoEntity> findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit);
}

6
dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeRepository.java

@ -69,12 +69,6 @@ public interface WidgetTypeRepository extends JpaRepository<WidgetTypeDetailsEnt
WidgetTypeDetailsEntity findByTenantIdAndFqn(UUID tenantId, String fqn);
@Query(value = "SELECT name FROM widget_type wt " +
"WHERE wt.tenant_id = :tenantId AND cast(wt.descriptor as json) ->> 'resources' LIKE concat('%', :resourceLink, '%')",
nativeQuery = true)
List<String> findNamesByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId,
@Param("resourceLink") String resourceLink);
@Query("SELECT externalId FROM WidgetTypeDetailsEntity WHERE id = :id")
UUID getExternalIdById(@Param("id") UUID id);

5
dao/src/main/java/org/thingsboard/server/dao/widget/WidgetTypeDao.java

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.widget.WidgetsBundleWidget;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao;
import org.thingsboard.server.dao.ImageContainerDao;
import org.thingsboard.server.dao.ResourceContainerDao;
import java.util.List;
import java.util.UUID;
@ -35,7 +36,7 @@ import java.util.UUID;
/**
* The Interface WidgetTypeDao.
*/
public interface WidgetTypeDao extends Dao<WidgetTypeDetails>, ExportableEntityDao<WidgetTypeId, WidgetTypeDetails>, ImageContainerDao<WidgetTypeInfo> {
public interface WidgetTypeDao extends Dao<WidgetTypeDetails>, ExportableEntityDao<WidgetTypeId, WidgetTypeDetails>, ImageContainerDao<WidgetTypeInfo>, ResourceContainerDao<WidgetTypeInfo> {
/**
* Save or update widget type object
@ -97,8 +98,6 @@ public interface WidgetTypeDao extends Dao<WidgetTypeDetails>, ExportableEntityD
WidgetTypeDetails findDetailsByTenantIdAndFqn(UUID tenantId, String fqn);
List<String> findWidgetTypesNamesByTenantIdAndResourceLink(UUID tenantId, String link);
List<WidgetTypeId> findWidgetTypeIdsByTenantIdAndFqns(UUID tenantId, List<String> widgetFqns);
List<WidgetsBundleWidget> findWidgetsBundleWidgetsByWidgetsBundleId(UUID tenantId, UUID widgetsBundleId);

3
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -143,8 +143,7 @@ public interface TbContext {
void tellFailure(TbMsg msg, Throwable th);
/**
* Puts new message to queue for processing by the Root Rule Chain
* WARNING: message is put to the Main queue. To specify other queue name - use {@link #enqueue(TbMsg, String, Runnable, Consumer)}
* Puts new message to queue from TbMsg for processing by the Root Rule Chain
*
* @param msg - message
*/

4
ui-ngx/src/app/core/http/resource.service.ts

@ -90,8 +90,8 @@ export class ResourceService {
return this.http.post<Resource>('/api/resource', resource, defaultHttpOptionsFromConfig(config));
}
public deleteResource(resourceId: string, config?: RequestConfig) {
return this.http.delete(`/api/resource/${resourceId}`, defaultHttpOptionsFromConfig(config));
public deleteResource(resourceId: string, force = false, config?: RequestConfig) {
return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config));
}
}

14
ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html

@ -73,20 +73,6 @@
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.key-serializer</mat-label>
<input required matInput formControlName="keySerializer">
<mat-error *ngIf="kafkaConfigForm.get('keySerializer').hasError('required')">
{{ 'rule-node-config.key-serializer-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.value-serializer</mat-label>
<input required matInput formControlName="valueSerializer">
<mat-error *ngIf="kafkaConfigForm.get('valueSerializer').hasError('required')">
{{ 'rule-node-config.value-serializer-required' | translate }}
</mat-error>
</mat-form-field>
<label translate class="tb-title">rule-node-config.other-properties</label>
<tb-kv-map-config-old
required="false"

2
ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts

@ -54,8 +54,6 @@ export class KafkaConfigComponent extends RuleNodeConfigurationComponent {
linger: [configuration ? configuration.linger : null, [Validators.min(0)]],
bufferMemory: [configuration ? configuration.bufferMemory : null, [Validators.min(0)]],
acks: [configuration ? configuration.acks : null, [Validators.required]],
keySerializer: [configuration ? configuration.keySerializer : null, [Validators.required]],
valueSerializer: [configuration ? configuration.valueSerializer : null, [Validators.required]],
otherProperties: [configuration ? configuration.otherProperties : null, []],
addMetadataKeyValuesAsKafkaHeaders: [configuration ? configuration.addMetadataKeyValuesAsKafkaHeaders : false, []],
kafkaHeadersCharset: [configuration ? configuration.kafkaHeadersCharset : null, []]

191
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts

@ -16,6 +16,7 @@
import { Injectable } from '@angular/core';
import {
CellActionDescriptor,
checkBoxCell,
DateEntityTableColumn,
EntityTableColumn,
@ -27,7 +28,9 @@ import {
ResourceInfo,
ResourceSubType,
ResourceSubTypeTranslationMap,
ResourceType
ResourceType,
ResourceInfoWithReferences,
toResourceDeleteResult
} from '@shared/models/resource.models';
import { EntityType, entityTypeResources } from '@shared/models/entity-type.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@ -42,8 +45,19 @@ import { PageLink } from '@shared/models/page/page-link';
import { EntityAction } from '@home/models/entity/entity-component.models';
import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-library-table-header.component';
import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component';
import { switchMap } from 'rxjs/operators';
import { catchError, map, switchMap } from 'rxjs/operators';
import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component';
import { forkJoin, of } from 'rxjs';
import { parseHttpErrorMessage } from '@core/utils';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { MatDialog } from '@angular/material/dialog';
import { DialogService } from '@core/services/dialog.service';
import {
ResourcesInUseDialogComponent,
ResourcesInUseDialogData
} from "@shared/components/resource/resources-in-use-dialog.component";
import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource";
import { AuthUser } from '@shared/models/user.model';
@Injectable()
export class JsLibraryTableConfigResolver {
@ -53,6 +67,8 @@ export class JsLibraryTableConfigResolver {
constructor(private store: Store<AppState>,
private resourceService: ResourceService,
private translate: TranslateService,
private dialog: MatDialog,
private dialogService: DialogService,
private router: Router,
private datePipe: DatePipe) {
@ -81,20 +97,16 @@ export class JsLibraryTableConfigResolver {
entity => checkBoxCell(entity.tenantId.id === NULL_UUID)),
);
this.config.cellActionDescriptors.push(
{
name: this.translate.instant('javascript.download'),
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
}
);
this.config.cellActionDescriptors = this.configureCellActions(getCurrentAuthUser(this.store));
this.config.deleteEntityTitle = resource => this.translate.instant('javascript.delete-javascript-resource-title',
{ resourceTitle: resource.title });
this.config.deleteEntityContent = () => this.translate.instant('javascript.delete-javascript-resource-text');
this.config.deleteEntitiesTitle = count => this.translate.instant('javascript.delete-javascript-resources-title', {count});
this.config.deleteEntitiesContent = () => this.translate.instant('javascript.delete-javascript-resources-text');
this.config.groupActionDescriptors = [{
name: this.translate.instant('action.delete'),
icon: 'delete',
isEnabled: true,
onAction: ($event, entities) => this.deleteResources($event, entities)
}];
this.config.entitiesDeleteEnabled = false;
this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, ResourceType.JS_MODULE, this.config.componentsData.resourceSubType);
this.config.loadEntity = id => {
@ -115,7 +127,6 @@ export class JsLibraryTableConfigResolver {
}
return saveObservable;
};
this.config.deleteEntity = id => this.resourceService.deleteResource(id.id);
this.config.onEntityAction = action => this.onResourceAction(action);
}
@ -126,7 +137,6 @@ export class JsLibraryTableConfigResolver {
resourceSubType: ''
};
const authUser = getCurrentAuthUser(this.store);
this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
this.config.entitySelectionEnabled = (resource) => this.isResourceEditable(resource, authUser.authority);
this.config.detailsReadonly = (resource) => this.detailsReadonly(resource, authUser.authority);
return this.config;
@ -170,4 +180,151 @@ export class JsLibraryTableConfigResolver {
return authority === Authority.SYS_ADMIN;
}
}
private deleteResource($event: Event, resource: ResourceInfo) {
if ($event) {
$event.stopPropagation();
}
this.dialogService.confirm(
this.translate.instant('javascript.delete-javascript-resource-title', { resourceTitle: resource.title }),
this.translate.instant('javascript.delete-javascript-resource-text'),
this.translate.instant('action.no'),
this.translate.instant('action.yes'),
true
).subscribe((result) => {
if (result) {
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
).subscribe(
(deleteResult) => {
if (deleteResult.success) {
this.config.updateData();
} else if (deleteResult.resourceIsReferencedError) {
const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}];
const data = {
multiple: false,
resources,
configuration: {
title: 'javascript.javascript-resource-is-in-use',
message: this.translate.instant('javascript.javascript-resource-is-in-use-text', {title: resources[0].title}),
deleteText: 'javascript.delete-javascript-resource-in-use-text',
selectedText: 'javascript.selected-javascript-resources',
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((resources) => {
if (resources) {
this.resourceService.deleteResource(resource.id.id, true).subscribe(
() => {
this.config.updateData();
}
);
}
});
} else {
const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
private deleteResources($event: Event, resources: ResourceInfo[]) {
if ($event) {
$event.stopPropagation();
}
if (resources && resources.length) {
const title = this.translate.instant('javascript.delete-javascript-resources-title', {count: resources.length});
const content = this.translate.instant('javascript.delete-javascript-resources-text');
this.dialogService.confirm(title, content,
this.translate.instant('action.no'),
this.translate.instant('action.yes')).subscribe((result) => {
if (result) {
const tasks = resources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe(
map(() => toResourceDeleteResult(resource)),
catchError((err) => of(toResourceDeleteResult(resource, err)))
)
);
forkJoin(tasks).subscribe(
(deleteResults) => {
const anySuccess = deleteResults.some(res => res.success);
const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError);
const otherError = deleteResults.find(res => !res.success);
if (anySuccess) {
this.config.updateData();
}
if (referenceErrors?.length) {
const resourcesWithReferences: ResourceInfoWithReferences[] =
referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}}));
const data = {
multiple: true,
resources: resourcesWithReferences,
configuration: {
title: 'javascript.javascript-resources-are-in-use',
message: this.translate.instant('javascript.javascript-resources-are-in-use-text'),
deleteText: 'javascript.delete-javascript-resource-in-use-text',
selectedText: 'javascript.selected-javascript-resources',
datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, entity => true),
columns: ['select', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data
}).afterClosed().subscribe((forceDeleteResources) => {
if (forceDeleteResources && forceDeleteResources.length) {
const forceDeleteTasks = forceDeleteResources.map((resource) =>
this.resourceService.deleteResource(resource.id.id, true)
);
forkJoin(forceDeleteTasks).subscribe(
() => {
this.config.updateData();
}
);
}
});
} else if (otherError) {
const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate);
setTimeout(() => {
this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'}));
}, errorMessageWithTimeout.timeout);
}
}
);
}
});
}
}
private configureCellActions(authUser: AuthUser): Array<CellActionDescriptor<ResourceInfo>> {
const actions: Array<CellActionDescriptor<ResourceInfo>> = [];
actions.push(
{
name: this.translate.instant('javascript.download'),
icon: 'file_download',
isEnabled: () => true,
onAction: ($event, entity) => this.downloadResource($event, entity)
},
{
name: this.translate.instant('javascript.delete'),
icon: 'delete',
isEnabled: (resource) => this.isResourceEditable(resource, authUser.authority),
onAction: ($event, entity) => this.deleteResource($event, entity)
},
);
return actions;
}
}

130
ui-ngx/src/app/modules/home/pages/admin/resource/resources-datasource.ts

@ -0,0 +1,130 @@
///
/// Copyright © 2016-2025 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.
///
import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections';
import { ResourceInfo, ResourceSubType, ResourceType } from '@shared/models/resource.models';
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityBooleanFunction } from '@home/models/entity/entities-table-config.models';
import { PageLink } from '@shared/models/page/page-link';
import { catchError, map, take, tap } from 'rxjs/operators';
import { ResourceService } from "@core/http/resource.service";
export class ResourcesDatasource implements DataSource<ResourceInfo> {
private entitiesSubject: Subject<ResourceInfo[]>;
private readonly pageDataSubject: Subject<PageData<ResourceInfo>>;
public pageData$: Observable<PageData<ResourceInfo>>;
public selection = new SelectionModel<ResourceInfo>(true, []);
public dataLoading = true;
constructor(private resourceService: ResourceService,
private resources: ResourceInfo[],
private selectionEnabledFunction: EntityBooleanFunction<ResourceInfo>) {
if (this.resources && this.resources.length) {
this.entitiesSubject = new BehaviorSubject<ResourceInfo[]>(this.resources);
} else {
this.entitiesSubject = new BehaviorSubject<ResourceInfo[]>([]);
this.pageDataSubject = new BehaviorSubject<PageData<ResourceInfo>>(emptyPageData<ResourceInfo>());
this.pageData$ = this.pageDataSubject.asObservable();
}
}
connect(collectionViewer: CollectionViewer):
Observable<ResourceInfo[] | ReadonlyArray<ResourceInfo>> {
return this.entitiesSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.entitiesSubject.complete();
if (this.pageDataSubject) {
this.pageDataSubject.complete();
}
}
reset() {
this.entitiesSubject.next([]);
if (this.pageDataSubject) {
this.pageDataSubject.next(emptyPageData<ResourceInfo>());
}
}
loadEntities(pageLink: PageLink, resourceType: ResourceType, subType: ResourceSubType): Observable<PageData<ResourceInfo>> {
this.dataLoading = true;
const result = new ReplaySubject<PageData<ResourceInfo>>();
this.fetchEntities(pageLink, resourceType, subType).pipe(
tap(() => {
this.selection.clear();
}),
catchError(() => of(emptyPageData<ResourceInfo>())),
).subscribe(
(pageData) => {
this.entitiesSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
result.next(pageData);
this.dataLoading = false;
}
);
return result;
}
fetchEntities(pageLink: PageLink, resourceType: ResourceType, subType: ResourceSubType): Observable<PageData<ResourceInfo>> {
return this.resourceService.getResources(pageLink, resourceType, subType);
}
isAllSelected(): Observable<boolean> {
const numSelected = this.selection.selected.length;
return this.entitiesSubject.pipe(
map((entities) => numSelected === entities.length)
);
}
isEmpty(): Observable<boolean> {
return this.entitiesSubject.pipe(
map((entities) => !entities.length)
);
}
total(): Observable<number> {
return this.pageDataSubject.pipe(
map((pageData) => pageData.totalElements)
);
}
masterToggle() {
this.entitiesSubject.pipe(
tap((entities) => {
const numSelected = this.selection.selected.length;
if (numSelected === this.selectableEntitiesCount(entities)) {
this.selection.clear();
} else {
entities.forEach(row => {
if (this.selectionEnabledFunction(row)) {
this.selection.select(row);
}
});
}
}),
take(1)
).subscribe();
}
private selectableEntitiesCount(entities: Array<ResourceInfo>): number {
return entities.filter((entity) => this.selectionEnabledFunction(entity)).length;
}
}

68
ui-ngx/src/app/shared/components/image/image-gallery.component.ts

@ -16,10 +16,10 @@
import {
ImageResourceInfo,
ImageResourceInfoWithReferences,
ResourceInfoWithReferences,
imageResourceType,
ResourceSubType,
toImageDeleteResult
toResourceDeleteResult
} from '@shared/models/resource.models';
import { forkJoin, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { ImageService } from '@core/http/image.service';
@ -67,9 +67,9 @@ import { ImageDialogComponent, ImageDialogData } from '@shared/components/image/
import { ImportExportService } from '@shared/import-export/import-export.service';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import {
ImagesInUseDialogComponent,
ImagesInUseDialogData
} from '@shared/components/image/images-in-use-dialog.component';
ResourcesInUseDialogComponent,
ResourcesInUseDialogData
} from '@shared/components/resource/resources-in-use-dialog.component';
import { ImagesDatasource } from '@shared/components/image/images-datasource';
import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component';
@ -504,21 +504,30 @@ export class ImageGalleryComponent extends PageComponent implements OnInit, OnDe
this.translate.instant('action.yes')).subscribe((result) => {
if (result) {
this.imageService.deleteImage(imageResourceType(image), image.resourceKey, false, {ignoreErrors: true}).pipe(
map(() => toImageDeleteResult(image)),
catchError((err) => of(toImageDeleteResult(image, err)))
map(() => toResourceDeleteResult(image)),
catchError((err) => of(toResourceDeleteResult(image, err)))
).subscribe(
(deleteResult) => {
if (deleteResult.success) {
this.imageDeleted(itemIndex);
} else if (deleteResult.imageIsReferencedError) {
this.dialog.open<ImagesInUseDialogComponent, ImagesInUseDialogData,
ImageResourceInfo[]>(ImagesInUseDialogComponent, {
} else if (deleteResult.resourceIsReferencedError) {
const images = [{...image, ...{references: deleteResult.references}}];
const data = {
multiple: false,
resources: images,
configuration: {
title: 'image.image-is-in-use',
message: this.translate.instant('image.image-is-in-use-text', {title: images[0].title}),
deleteText: 'image.delete-image-in-use-text',
selectedText: 'image.selected-images',
columns: ['select', 'preview', 'title', 'references']
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ImageResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
multiple: false,
images: [{...image, ...{references: deleteResult.references}}]
}
data
}).afterClosed().subscribe((images) => {
if (images) {
this.imageService.deleteImage(imageResourceType(image), image.resourceKey, true).subscribe(
@ -554,29 +563,38 @@ export class ImageGalleryComponent extends PageComponent implements OnInit, OnDe
if (result) {
const tasks = selectedImages.map((image) =>
this.imageService.deleteImage(imageResourceType(image), image.resourceKey, false, {ignoreErrors: true}).pipe(
map(() => toImageDeleteResult(image)),
catchError((err) => of(toImageDeleteResult(image, err)))
map(() => toResourceDeleteResult(image)),
catchError((err) => of(toResourceDeleteResult(image, err)))
)
);
forkJoin(tasks).subscribe(
(deleteResults) => {
const anySuccess = deleteResults.some(res => res.success);
const referenceErrors = deleteResults.filter(res => res.imageIsReferencedError);
const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError);
const otherError = deleteResults.find(res => !res.success);
if (anySuccess) {
this.updateData();
}
if (referenceErrors?.length) {
const imagesWithReferences: ImageResourceInfoWithReferences[] =
referenceErrors.map(ref => ({...ref.image, ...{references: ref.references}}));
this.dialog.open<ImagesInUseDialogComponent, ImagesInUseDialogData,
ImageResourceInfo[]>(ImagesInUseDialogComponent, {
const imagesWithReferences: ResourceInfoWithReferences[] =
referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}}));
const data = {
multiple: true,
resources: imagesWithReferences,
configuration: {
title: 'image.images-are-in-use',
message: this.translate.instant('image.images-are-in-use-text'),
deleteText: 'image.delete-image-in-use-text',
selectedText: 'image.selected-images',
columns: ['select', 'preview', 'title', 'references'],
datasource: new ImagesDatasource(null, imagesWithReferences, entity => true)
}
};
this.dialog.open<ResourcesInUseDialogComponent, ResourcesInUseDialogData,
ImageResourceInfo[]>(ResourcesInUseDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
multiple: true,
images: imagesWithReferences
}
data
}).afterClosed().subscribe((forceDeleteImages) => {
if (forceDeleteImages && forceDeleteImages.length) {
const forceDeleteTasks = forceDeleteImages.map((image) =>

10
ui-ngx/src/app/shared/components/image/image-references.component.ts

@ -17,7 +17,7 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { ImageReferences } from '@shared/models/resource.models';
import { ResourceReferences } from '@shared/models/resource.models';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { getEntityDetailsPageURL } from '@core/utils';
@ -54,7 +54,7 @@ type ReferencedEntitiesEntry = [string, TenantReferencedEntities];
export class ImageReferencesComponent implements OnInit {
@Input()
references: ImageReferences;
references: ResourceReferences;
popoverComponent: TbPopoverComponent<ImageReferencesComponent>;
@ -99,7 +99,7 @@ export class ImageReferencesComponent implements OnInit {
return tenantId === NULL_UUID;
}
private hasNonSystemEntities(references: ImageReferences): boolean {
private hasNonSystemEntities(references: ResourceReferences): boolean {
for (const entityTypeStr of Object.keys(references)) {
const entities = this.references[entityTypeStr];
if (entities.some(e => e.tenantId && e.tenantId.id && e.tenantId.id !== NULL_UUID)) {
@ -109,7 +109,7 @@ export class ImageReferencesComponent implements OnInit {
return false;
}
private toReferencedEntitiesList(references: ImageReferences): ReferencedEntityInfo[] {
private toReferencedEntitiesList(references: ResourceReferences): ReferencedEntityInfo[] {
const result: ReferencedEntityInfo[] = [];
for (const entityTypeStr of Object.keys(references)) {
const entityType = entityTypeStr as EntityType;
@ -127,7 +127,7 @@ export class ImageReferencesComponent implements OnInit {
return result;
}
private toReferencedEntitiesEntries(references: ImageReferences): Observable<ReferencedEntitiesEntry[]> {
private toReferencedEntitiesEntries(references: ResourceReferences): Observable<ReferencedEntitiesEntry[]> {
let referencedEntities: ReferencedEntities = {};
const referencedEntitiesList = this.toReferencedEntitiesList(references);
for (const referencedEntityInfo of referencedEntitiesList) {

42
ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.html → ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html

@ -15,10 +15,10 @@
limitations under the License.
-->
<h2 mat-dialog-title>{{title}}</h2>
<div mat-dialog-content class="tb-images-in-use-content" [class]="{multiple: data.multiple}">
<div [innerHTML]="message | safe: 'html'"></div>
<ng-container *ngIf="data.multiple; else singleImageReferences">
<h2 mat-dialog-title translate>{{configuration.title}}</h2>
<div mat-dialog-content class="tb-resources-in-use-content" [class]="{multiple: data.multiple}">
<div [innerHTML]="configuration.message | safe: 'html'"></div>
<ng-container *ngIf="data.multiple; else singleResourceReferences">
<div class="table-container">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select" sticky>
@ -28,14 +28,14 @@
[indeterminate]="dataSource.selection.hasValue() && (dataSource.isAllSelected() | async) === false">
</mat-checkbox>
</mat-header-cell>
<mat-cell *matCellDef="let image">
<mat-cell *matCellDef="let resource">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? dataSource.selection.toggle(image) : null"
[checked]="dataSource.selection.isSelected(image)">
(change)="$event ? dataSource.selection.toggle(resource) : null"
[checked]="dataSource.selection.isSelected(resource)">
</mat-checkbox>
</mat-cell>
</ng-container>
<ng-container matColumnDef="preview">
<ng-container matColumnDef="preview" *ngIf="displayPreview">
<mat-header-cell *matHeaderCellDef style="width: 50px; min-width: 50px;"></mat-header-cell>
<mat-cell *matCellDef="let image">
<img class="tb-image-preview" [src]="image.link | image: {preview: true} | async" alt="{{ image.title }}">
@ -43,30 +43,30 @@
</ng-container>
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef style="width: 100%;">
<ng-container *ngIf="dataSource.selection.isEmpty(); else selectedImages">
{{ 'image.name' | translate }}
<ng-container *ngIf="dataSource.selection.isEmpty(); else selectedResources">
{{ 'resource.title' | translate }}
</ng-container>
<ng-template #selectedImages>
{{ translate.get('image.selected-images', {count: dataSource.selection.selected.length}) | async }}
<ng-template #selectedResources>
{{ translate.get(configuration.selectedText, {count: dataSource.selection.selected.length}) | async }}
</ng-template>
</mat-header-cell>
<mat-cell *matCellDef="let image">
{{ image.title }}
<mat-cell *matCellDef="let resource">
{{ resource.title }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="references">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let image">
<mat-cell *matCellDef="let resource">
<button #showReferencesButton
mat-stroked-button
color="primary"
(click)="toggleShowReferences($event, image, showReferencesButton)">{{ 'image.references' | translate }}</button>
(click)="toggleShowReferences($event, resource, showReferencesButton)">{{ 'image.references' | translate }}</button>
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select" *matHeaderRowDef="['select', 'preview', 'title', 'references']; sticky: true"></mat-header-row>
<mat-header-row class="mat-row-select" *matHeaderRowDef="configuration.columns; sticky: true"></mat-header-row>
<mat-row class="mat-row-select"
[class.mat-selected]="dataSource.selection.isSelected(image)"
*matRowDef="let image; columns: ['select', 'preview', 'title', 'references'];"></mat-row>
[class.mat-selected]="dataSource.selection.isSelected(resource)"
*matRowDef="let resource; columns: configuration.columns;"></mat-row>
</table>
</div>
</ng-container>
@ -76,7 +76,7 @@
<button mat-raised-button color="accent" (click)="delete()" [disabled]="data.multiple && dataSource.selection.isEmpty()"
cdkFocusInitial>{{ (data.multiple ? 'action.delete-selected' : 'action.delete-anyway') | translate}}</button>
</div>
<ng-template #singleImageReferences>
<ng-template #singleResourceReferences>
<tb-image-references [references]="references"></tb-image-references>
<div [innerHTML]="'image.delete-image-in-use-text' | translate | safe: 'html'"></div>
<div [innerHTML]="configuration.deleteText | translate | safe: 'html'"></div>
</ng-template>

2
ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss → ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.scss

@ -14,7 +14,7 @@
* limitations under the License.
*/
:host {
.tb-images-in-use-content {
.tb-resources-in-use-content {
display: flex;
flex-direction: column;
gap: 24px;

61
ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts → ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts

@ -20,37 +20,50 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';
import { ImageReferences, ImageResourceInfo, ImageResourceInfoWithReferences } from '@shared/models/resource.models';
import { ImagesDatasource } from '@shared/components/image/images-datasource';
import {
ResourceReferences,
ResourceInfoWithReferences,
ResourceInfo
} from '@shared/models/resource.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { ImageReferencesComponent } from '@shared/components/image/image-references.component';
import { TranslateService } from '@ngx-translate/core';
import { Datasource } from "@shared/models/widget.models";
export interface ImagesInUseDialogData {
interface ResourcesInUseDialogDataConfiguration {
title: string;
message: string;
columns: string[];
deleteText: string;
selectedText: string;
datasource?: Datasource;
}
export interface ResourcesInUseDialogData {
multiple: boolean;
images: ImageResourceInfoWithReferences[];
resources: ResourceInfoWithReferences[];
configuration: ResourcesInUseDialogDataConfiguration;
}
@Component({
selector: 'tb-images-in-use-dialog',
templateUrl: './images-in-use-dialog.component.html',
styleUrls: ['./images-in-use-dialog.component.scss']
selector: 'tb-resources-in-use-dialog',
templateUrl: './resources-in-use-dialog.component.html',
styleUrls: ['./resources-in-use-dialog.component.scss']
})
export class ImagesInUseDialogComponent extends
DialogComponent<ImagesInUseDialogComponent, ImageResourceInfo[]> implements OnInit {
title: string;
message: string;
export class ResourcesInUseDialogComponent extends
DialogComponent<ResourcesInUseDialogComponent, ResourceInfo[]> implements OnInit {
references: ImageReferences;
displayPreview: boolean;
configuration: ResourcesInUseDialogDataConfiguration;
references: ResourceReferences;
dataSource: ImagesDatasource;
dataSource: Datasource;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: ImagesInUseDialogData,
public dialogRef: MatDialogRef<ImagesInUseDialogComponent, ImageResourceInfo[]>,
@Inject(MAT_DIALOG_DATA) public data: ResourcesInUseDialogData,
public dialogRef: MatDialogRef<ResourcesInUseDialogComponent, ResourceInfo[]>,
public translate: TranslateService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
@ -59,14 +72,12 @@ export class ImagesInUseDialogComponent extends
}
ngOnInit(): void {
this.configuration = this.data.configuration;
this.displayPreview = this.data.configuration.columns.includes('preview');
if (this.data.multiple) {
this.title = this.translate.instant('image.images-are-in-use');
this.message = this.translate.instant('image.images-are-in-use-text');
this.dataSource = new ImagesDatasource(null, this.data.images, entity => true);
this.dataSource = this.data.configuration.datasource;
} else {
this.title = this.translate.instant('image.image-is-in-use');
this.message = this.translate.instant('image.image-is-in-use-text', {title: this.data.images[0].title});
this.references = this.data.images[0].references;
this.references = this.data.resources[0].references;
}
}
@ -78,11 +89,11 @@ export class ImagesInUseDialogComponent extends
if (this.data.multiple) {
this.dialogRef.close(this.dataSource.selection.selected);
} else {
this.dialogRef.close(this.data.images);
this.dialogRef.close(this.data.resources);
}
}
toggleShowReferences($event: Event, image: ImageResourceInfoWithReferences, referencesButton: MatButton) {
toggleShowReferences($event: Event, resource: ResourceInfoWithReferences, referencesButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
@ -93,7 +104,7 @@ export class ImagesInUseDialogComponent extends
const referencesPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, ImageReferencesComponent, 'top', true, null,
{
references: image.references
references: resource.references
}, {}, {}, {},
false,
visible => {

24
ui-ngx/src/app/shared/models/resource.models.ts

@ -120,28 +120,28 @@ export interface ImageExportData {
export type ImageResourceType = 'tenant' | 'system';
export type TBResourceScope = 'tenant' | 'system';
export type ImageReferences = {[entityType: string]: Array<BaseData<HasId> & HasTenantId>};
export type ResourceReferences = {[entityType: string]: Array<BaseData<HasId> & HasTenantId>};
export interface ImageResourceInfoWithReferences extends ImageResourceInfo {
references: ImageReferences;
export interface ResourceInfoWithReferences extends ResourceInfo {
references: ResourceReferences;
}
export interface ImageDeleteResult {
image: ImageResourceInfo;
export interface ResourceDeleteResult {
resource: TbResourceInfo<any>;
success: boolean;
imageIsReferencedError?: boolean;
resourceIsReferencedError?: boolean;
error?: any;
references?: ImageReferences;
references?: ResourceReferences;
}
export const toImageDeleteResult = (image: ImageResourceInfo, e?: any): ImageDeleteResult => {
export const toResourceDeleteResult = (resource: ResourceInfo, e?: any): ResourceDeleteResult => {
if (!e) {
return {image, success: true};
return {resource, success: true};
} else {
const result: ImageDeleteResult = {image, success: false, error: e};
const result: ResourceDeleteResult = {resource, success: false, error: e};
if (e?.status === 400 && e?.error?.success === false && e?.error?.references) {
const references: ImageReferences = e?.error?.references;
result.imageIsReferencedError = true;
const references: ResourceReferences = e?.error?.references;
result.resourceIsReferencedError = true;
result.references = references;
}
return result;

6
ui-ngx/src/app/shared/shared.module.ts

@ -201,7 +201,7 @@ import { ImageGalleryComponent } from '@shared/components/image/image-gallery.co
import { UploadImageDialogComponent } from '@shared/components/image/upload-image-dialog.component';
import { ImageDialogComponent } from '@shared/components/image/image-dialog.component';
import { ImageReferencesComponent } from '@shared/components/image/image-references.component';
import { ImagesInUseDialogComponent } from '@shared/components/image/images-in-use-dialog.component';
import { ResourcesInUseDialogComponent } from '@shared/components/resource/resources-in-use-dialog.component';
import { GalleryImageInputComponent } from '@shared/components/image/gallery-image-input.component';
import { MultipleGalleryImageInputComponent } from '@shared/components/image/multiple-gallery-image-input.component';
import { EmbedImageDialogComponent } from '@shared/components/image/embed-image-dialog.component';
@ -425,7 +425,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
UploadImageDialogComponent,
ImageDialogComponent,
ImageReferencesComponent,
ImagesInUseDialogComponent,
ResourcesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent,
@ -688,7 +688,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
UploadImageDialogComponent,
ImageDialogComponent,
ImageReferencesComponent,
ImagesInUseDialogComponent,
ResourcesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent,

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

@ -4265,6 +4265,7 @@
"delete-javascript-resources-action-title": "Delete JavaScript { count, plural, =1 {1 resource} other {# resources} }",
"delete-javascript-resources-text": "Please note that the selected JavaScript resources, even if they are used in JavaScript functions, will be deleted.",
"delete-javascript-resources-title": "Are you sure you want to delete JavaScript { count, plural, =1 {1 resource} other {# resources} }?",
"delete-javascript-resource-in-use-text": "If you still want to delete the JavaScript resource, click the <b>Delete anyway</b> button.",
"download": "Download JavaScript resource",
"upload-from-file": "Upload JavaScript from file",
"resource-file": "JavaScript resource file",
@ -4273,6 +4274,10 @@
"javascript-library": "JavaScript library",
"javascript-type": "JavaScript type",
"javascript-resource-details": "JavaScript resource details",
"javascript-resource-is-in-use": "JavaScript resource is used by other entities",
"javascript-resources-are-in-use": "JavaScript resources are used by other entities",
"javascript-resource-is-in-use-text": "The JavaScript resource <b>'{{title}}'</b> was not deleted because it is used by the following entities:",
"javascript-resources-are-in-use-text": "Not all JavaScript resources have been deleted because they are used by other entities.</br>You can view referenced entities by clicking the <b>References</b> button in the corresponding resource row.</br>If you still want to delete these JavaScript resources, select them in the table below and click the <b>Delete selected</b> button.",
"search": "Search JavaScript resources",
"selected-javascript-resources": "{ count, plural, =1 {1 JavaScript resource} other {# JavaScript resources} } selected",
"no-javascript-resource-text": "No JavaScript resources found",
@ -4730,10 +4735,6 @@
"min-buffer-memory-message": "Only 0 minimum buffer size is allowed.",
"memory-buffer-size-range": "Memory buffer size must be between 0 and {{max}} KB",
"acks": "Number of acknowledgments",
"key-serializer": "Key serializer",
"key-serializer-required": "Key serializer is required",
"value-serializer": "Value serializer",
"value-serializer-required": "Value serializer is required",
"topic-arn-pattern": "Topic ARN pattern",
"topic-arn-pattern-required": "Topic ARN pattern is required",
"aws-access-key-id": "AWS Access Key ID",

Loading…
Cancel
Save