67 changed files with 2258 additions and 241 deletions
@ -0,0 +1,33 @@ |
|||
/** |
|||
* Copyright © 2016-2020 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.service.install; |
|||
|
|||
import org.springframework.context.annotation.Profile; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.dao.util.HsqlDao; |
|||
import org.thingsboard.server.dao.util.SqlDao; |
|||
|
|||
@Service |
|||
@HsqlDao |
|||
@SqlDao |
|||
@Profile("install") |
|||
public class HsqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService |
|||
implements EntityDatabaseSchemaService { |
|||
protected HsqlEntityDatabaseSchemaService() { |
|||
super("schema-entities-hsql.sql", "schema-entities-idx.sql"); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,253 @@ |
|||
package org.thingsboard.server.service.install.migrate; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.context.annotation.Profile; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.dao.cassandra.CassandraCluster; |
|||
import org.thingsboard.server.dao.util.SqlDao; |
|||
import org.thingsboard.server.service.install.EntityDatabaseSchemaService; |
|||
|
|||
import java.sql.Connection; |
|||
import java.sql.DriverManager; |
|||
import java.util.Arrays; |
|||
import java.util.List; |
|||
|
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.bigintColumn; |
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.booleanColumn; |
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.doubleColumn; |
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.enumToIntColumn; |
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.idColumn; |
|||
import static org.thingsboard.server.service.install.migrate.CassandraToSqlColumn.stringColumn; |
|||
|
|||
@Service |
|||
@Profile("install") |
|||
@SqlDao |
|||
@Slf4j |
|||
public class CassandraEntitiesToSqlMigrateService implements EntitiesMigrateService { |
|||
|
|||
@Autowired |
|||
private EntityDatabaseSchemaService entityDatabaseSchemaService; |
|||
|
|||
@Autowired |
|||
protected CassandraCluster cluster; |
|||
|
|||
@Value("${spring.datasource.url}") |
|||
protected String dbUrl; |
|||
|
|||
@Value("${spring.datasource.username}") |
|||
protected String dbUserName; |
|||
|
|||
@Value("${spring.datasource.password}") |
|||
protected String dbPassword; |
|||
|
|||
|
|||
@Override |
|||
public void migrate() throws Exception { |
|||
log.info("Performing migration of entities data from cassandra to SQL database ..."); |
|||
entityDatabaseSchemaService.createDatabaseSchema(); |
|||
try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { |
|||
conn.setAutoCommit(true); |
|||
for (CassandraToSqlTable table: tables) { |
|||
table.migrateToSql(cluster.getSession(), conn); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("Unexpected error during ThingsBoard entities data migration!", e); |
|||
throw e; |
|||
} |
|||
} |
|||
|
|||
private static List<CassandraToSqlTable> tables = Arrays.asList( |
|||
new CassandraToSqlTable("admin_settings", |
|||
idColumn("id"), |
|||
stringColumn("key"), |
|||
stringColumn("json_value")), |
|||
new CassandraToSqlTable("alarm", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("type"), |
|||
idColumn("originator_id"), |
|||
enumToIntColumn("originator_type", EntityType.class), |
|||
stringColumn("severity"), |
|||
stringColumn("status"), |
|||
bigintColumn("start_ts"), |
|||
bigintColumn("end_ts"), |
|||
bigintColumn("ack_ts"), |
|||
bigintColumn("clear_ts"), |
|||
stringColumn("details", "additional_info"), |
|||
booleanColumn("propagate"), |
|||
stringColumn("propagate_relation_types")), |
|||
new CassandraToSqlTable("asset", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("customer_id"), |
|||
stringColumn("name"), |
|||
stringColumn("type"), |
|||
stringColumn("label"), |
|||
stringColumn("search_text"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("audit_log_by_tenant_id", "audit_log", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("customer_id"), |
|||
idColumn("entity_id"), |
|||
stringColumn("entity_type"), |
|||
stringColumn("entity_name"), |
|||
idColumn("user_id"), |
|||
stringColumn("user_name"), |
|||
stringColumn("action_type"), |
|||
stringColumn("action_data"), |
|||
stringColumn("action_status"), |
|||
stringColumn("action_failure_details")), |
|||
new CassandraToSqlTable("attributes_kv_cf", "attribute_kv", |
|||
idColumn("entity_id"), |
|||
stringColumn("entity_type"), |
|||
stringColumn("attribute_type"), |
|||
stringColumn("attribute_key"), |
|||
booleanColumn("bool_v"), |
|||
stringColumn("str_v"), |
|||
bigintColumn("long_v"), |
|||
doubleColumn("dbl_v"), |
|||
stringColumn("json_v"), |
|||
bigintColumn("last_update_ts")), |
|||
new CassandraToSqlTable("component_descriptor", |
|||
idColumn("id"), |
|||
stringColumn("type"), |
|||
stringColumn("scope"), |
|||
stringColumn("name"), |
|||
stringColumn("search_text"), |
|||
stringColumn("clazz"), |
|||
stringColumn("configuration_descriptor"), |
|||
stringColumn("actions")), |
|||
new CassandraToSqlTable("customer", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("title"), |
|||
stringColumn("search_text"), |
|||
stringColumn("country"), |
|||
stringColumn("state"), |
|||
stringColumn("city"), |
|||
stringColumn("address"), |
|||
stringColumn("address2"), |
|||
stringColumn("zip"), |
|||
stringColumn("phone"), |
|||
stringColumn("email"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("dashboard", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("title"), |
|||
stringColumn("search_text"), |
|||
stringColumn("assigned_customers"), |
|||
stringColumn("configuration")), |
|||
new CassandraToSqlTable("device", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("customer_id"), |
|||
stringColumn("name"), |
|||
stringColumn("type"), |
|||
stringColumn("label"), |
|||
stringColumn("search_text"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("device_credentials", |
|||
idColumn("id"), |
|||
idColumn("device_id"), |
|||
stringColumn("credentials_type"), |
|||
stringColumn("credentials_id"), |
|||
stringColumn("credentials_value")), |
|||
new CassandraToSqlTable("event", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("entity_id"), |
|||
stringColumn("entity_type"), |
|||
stringColumn("event_type"), |
|||
stringColumn("event_uid"), |
|||
stringColumn("body")), |
|||
new CassandraToSqlTable("relation", |
|||
idColumn("from_id"), |
|||
stringColumn("from_type"), |
|||
idColumn("to_id"), |
|||
stringColumn("to_type"), |
|||
stringColumn("relation_type_group"), |
|||
stringColumn("relation_type"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("user", "tb_user", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("customer_id"), |
|||
stringColumn("email"), |
|||
stringColumn("search_text"), |
|||
stringColumn("authority"), |
|||
stringColumn("first_name"), |
|||
stringColumn("last_name"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("tenant", |
|||
idColumn("id"), |
|||
stringColumn("title"), |
|||
stringColumn("search_text"), |
|||
stringColumn("region"), |
|||
stringColumn("country"), |
|||
stringColumn("state"), |
|||
stringColumn("city"), |
|||
stringColumn("address"), |
|||
stringColumn("address2"), |
|||
stringColumn("zip"), |
|||
stringColumn("phone"), |
|||
stringColumn("email"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("user_credentials", |
|||
idColumn("id"), |
|||
idColumn("user_id"), |
|||
booleanColumn("enabled"), |
|||
stringColumn("password"), |
|||
stringColumn("activate_token"), |
|||
stringColumn("reset_token")), |
|||
new CassandraToSqlTable("widget_type", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("bundle_alias"), |
|||
stringColumn("alias"), |
|||
stringColumn("name"), |
|||
stringColumn("descriptor")), |
|||
new CassandraToSqlTable("widgets_bundle", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("alias"), |
|||
stringColumn("title"), |
|||
stringColumn("search_text")), |
|||
new CassandraToSqlTable("rule_chain", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
stringColumn("name"), |
|||
stringColumn("search_text"), |
|||
idColumn("first_rule_node_id"), |
|||
booleanColumn("root"), |
|||
booleanColumn("debug_mode"), |
|||
stringColumn("configuration"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("rule_node", |
|||
idColumn("id"), |
|||
idColumn("rule_chain_id"), |
|||
stringColumn("type"), |
|||
stringColumn("name"), |
|||
booleanColumn("debug_mode"), |
|||
stringColumn("search_text"), |
|||
stringColumn("configuration"), |
|||
stringColumn("additional_info")), |
|||
new CassandraToSqlTable("entity_view", |
|||
idColumn("id"), |
|||
idColumn("tenant_id"), |
|||
idColumn("customer_id"), |
|||
idColumn("entity_id"), |
|||
stringColumn("entity_type"), |
|||
stringColumn("name"), |
|||
stringColumn("type"), |
|||
stringColumn("keys"), |
|||
bigintColumn("start_ts"), |
|||
bigintColumn("end_ts"), |
|||
stringColumn("search_text"), |
|||
stringColumn("additional_info")) |
|||
); |
|||
} |
|||
@ -0,0 +1,159 @@ |
|||
package org.thingsboard.server.service.install.migrate; |
|||
|
|||
import com.datastax.driver.core.Row; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.UUIDConverter; |
|||
|
|||
import java.sql.PreparedStatement; |
|||
import java.sql.SQLException; |
|||
import java.sql.Types; |
|||
|
|||
@Data |
|||
public class CassandraToSqlColumn { |
|||
|
|||
private String cassandraColumnName; |
|||
private String sqlColumnName; |
|||
private CassandraToSqlColumnType type; |
|||
private int sqlType; |
|||
private Class<? extends Enum> enumClass; |
|||
|
|||
public static CassandraToSqlColumn idColumn(String name) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.ID); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn stringColumn(String name) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.STRING); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn stringColumn(String cassandraColumnName, String sqlColumnName) { |
|||
return new CassandraToSqlColumn(cassandraColumnName, sqlColumnName); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn bigintColumn(String name) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.BIGINT); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn doubleColumn(String name) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.DOUBLE); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn booleanColumn(String name) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.BOOLEAN); |
|||
} |
|||
|
|||
public static CassandraToSqlColumn enumToIntColumn(String name, Class<? extends Enum> enumClass) { |
|||
return new CassandraToSqlColumn(name, CassandraToSqlColumnType.ENUM_TO_INT, enumClass); |
|||
} |
|||
|
|||
public CassandraToSqlColumn(String columnName) { |
|||
this(columnName, columnName, CassandraToSqlColumnType.STRING, null); |
|||
} |
|||
|
|||
public CassandraToSqlColumn(String columnName, CassandraToSqlColumnType type) { |
|||
this(columnName, columnName, type, null); |
|||
} |
|||
|
|||
public CassandraToSqlColumn(String columnName, CassandraToSqlColumnType type, Class<? extends Enum> enumClass) { |
|||
this(columnName, columnName, type, enumClass); |
|||
} |
|||
|
|||
public CassandraToSqlColumn(String cassandraColumnName, String sqlColumnName) { |
|||
this(cassandraColumnName, sqlColumnName, CassandraToSqlColumnType.STRING, null); |
|||
} |
|||
|
|||
public CassandraToSqlColumn(String cassandraColumnName, String sqlColumnName, CassandraToSqlColumnType type, |
|||
Class<? extends Enum> enumClass) { |
|||
this.cassandraColumnName = cassandraColumnName; |
|||
this.sqlColumnName = sqlColumnName; |
|||
this.type = type; |
|||
this.enumClass = enumClass; |
|||
switch (this.type) { |
|||
case ID: |
|||
case STRING: |
|||
this.sqlType = Types.VARCHAR; |
|||
break; |
|||
case DOUBLE: |
|||
this.sqlType = Types.DOUBLE; |
|||
break; |
|||
case INTEGER: |
|||
case ENUM_TO_INT: |
|||
this.sqlType = Types.INTEGER; |
|||
break; |
|||
case FLOAT: |
|||
this.sqlType = Types.FLOAT; |
|||
break; |
|||
case BIGINT: |
|||
this.sqlType = Types.BIGINT; |
|||
break; |
|||
case BOOLEAN: |
|||
this.sqlType = Types.BOOLEAN; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
public void prepareColumnValue(Row row, PreparedStatement sqlInsertStatement, int index) throws SQLException { |
|||
String value = this.getColumnValue(row, index); |
|||
this.setColumnValue(sqlInsertStatement, index, value); |
|||
} |
|||
|
|||
private String getColumnValue(Row row, int index) { |
|||
if (row.isNull(index)) { |
|||
return null; |
|||
} else { |
|||
switch (this.type) { |
|||
case ID: |
|||
return UUIDConverter.fromTimeUUID(row.getUUID(index)); |
|||
case DOUBLE: |
|||
return Double.toString(row.getDouble(index)); |
|||
case INTEGER: |
|||
return Integer.toString(row.getInt(index)); |
|||
case FLOAT: |
|||
return Float.toString(row.getFloat(index)); |
|||
case BIGINT: |
|||
return Long.toString(row.getLong(index)); |
|||
case BOOLEAN: |
|||
return Boolean.toString(row.getBool(index)); |
|||
case STRING: |
|||
case ENUM_TO_INT: |
|||
default: |
|||
return row.getString(index); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private void setColumnValue(PreparedStatement sqlInsertStatement, int index, String value) throws SQLException { |
|||
if (value == null) { |
|||
sqlInsertStatement.setNull(index, this.sqlType); |
|||
} else { |
|||
switch (this.type) { |
|||
case DOUBLE: |
|||
sqlInsertStatement.setDouble(index, Double.parseDouble(value)); |
|||
break; |
|||
case INTEGER: |
|||
sqlInsertStatement.setInt(index, Integer.parseInt(value)); |
|||
break; |
|||
case FLOAT: |
|||
sqlInsertStatement.setFloat(index, Float.parseFloat(value)); |
|||
break; |
|||
case BIGINT: |
|||
sqlInsertStatement.setLong(index, Long.parseLong(value)); |
|||
break; |
|||
case BOOLEAN: |
|||
sqlInsertStatement.setBoolean(index, Boolean.parseBoolean(value)); |
|||
break; |
|||
case ENUM_TO_INT: |
|||
Enum enumVal = Enum.valueOf(this.enumClass, value); |
|||
int intValue = enumVal.ordinal(); |
|||
sqlInsertStatement.setInt(index, intValue); |
|||
break; |
|||
case STRING: |
|||
case ID: |
|||
default: |
|||
sqlInsertStatement.setString(index, value); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
@ -0,0 +1,12 @@ |
|||
package org.thingsboard.server.service.install.migrate; |
|||
|
|||
public enum CassandraToSqlColumnType { |
|||
ID, |
|||
DOUBLE, |
|||
INTEGER, |
|||
FLOAT, |
|||
BIGINT, |
|||
BOOLEAN, |
|||
STRING, |
|||
ENUM_TO_INT |
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
package org.thingsboard.server.service.install.migrate; |
|||
|
|||
import com.datastax.driver.core.ResultSet; |
|||
import com.datastax.driver.core.Row; |
|||
import com.datastax.driver.core.Session; |
|||
import com.datastax.driver.core.SimpleStatement; |
|||
import com.datastax.driver.core.Statement; |
|||
import lombok.Data; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
import java.sql.Connection; |
|||
import java.sql.PreparedStatement; |
|||
import java.sql.SQLException; |
|||
import java.util.Arrays; |
|||
import java.util.Iterator; |
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Slf4j |
|||
public class CassandraToSqlTable { |
|||
|
|||
private String cassandraCf; |
|||
private String sqlTableName; |
|||
|
|||
private List<CassandraToSqlColumn> columns; |
|||
|
|||
public CassandraToSqlTable(String tableName, CassandraToSqlColumn... columns) { |
|||
this(tableName, tableName, columns); |
|||
} |
|||
|
|||
public CassandraToSqlTable(String cassandraCf, String sqlTableName, CassandraToSqlColumn... columns) { |
|||
this.cassandraCf = cassandraCf; |
|||
this.sqlTableName = sqlTableName; |
|||
this.columns = Arrays.asList(columns); |
|||
} |
|||
|
|||
public void migrateToSql(Session session, Connection conn) throws SQLException { |
|||
log.info("Migrating data from cassandra '{}' Column Family to '{}' SQL table...", this.cassandraCf, this.sqlTableName); |
|||
PreparedStatement sqlInsertStatement = createSqlInsertStatement(conn); |
|||
Statement cassandraSelectStatement = createCassandraSelectStatement(); |
|||
cassandraSelectStatement.setFetchSize(100); |
|||
ResultSet rs = session.execute(cassandraSelectStatement); |
|||
Iterator<Row> iter = rs.iterator(); |
|||
int rowCounter = 0; |
|||
while (iter.hasNext()) { |
|||
Row row = iter.next(); |
|||
if (row != null) { |
|||
this.migrateRowToSql(row, sqlInsertStatement); |
|||
rowCounter++; |
|||
if (rowCounter % 100 == 0) { |
|||
sqlInsertStatement.executeBatch(); |
|||
log.info("{} records migrated so far...", rowCounter); |
|||
} |
|||
} |
|||
} |
|||
if (rowCounter % 100 > 0) { |
|||
sqlInsertStatement.executeBatch(); |
|||
} |
|||
sqlInsertStatement.close(); |
|||
log.info("{} total records migrated.", rowCounter); |
|||
log.info("Finished migration data from cassandra '{}' Column Family to '{}' SQL table.", this.cassandraCf, this.sqlTableName); |
|||
} |
|||
|
|||
private void migrateRowToSql(Row row, PreparedStatement sqlInsertStatement) throws SQLException { |
|||
for (int i=0; i<this.columns.size();i++) { |
|||
CassandraToSqlColumn column = this.columns.get(i); |
|||
column.prepareColumnValue(row, sqlInsertStatement, i); |
|||
} |
|||
sqlInsertStatement.addBatch(); |
|||
} |
|||
|
|||
private Statement createCassandraSelectStatement() { |
|||
StringBuilder selectStatementBuilder = new StringBuilder(); |
|||
selectStatementBuilder.append("SELECT "); |
|||
for (CassandraToSqlColumn column : columns) { |
|||
selectStatementBuilder.append(column.getCassandraColumnName()).append(","); |
|||
} |
|||
selectStatementBuilder.deleteCharAt(selectStatementBuilder.length() - 1); |
|||
selectStatementBuilder.append(" FROM ").append(cassandraCf); |
|||
return new SimpleStatement(selectStatementBuilder.toString()); |
|||
} |
|||
|
|||
private PreparedStatement createSqlInsertStatement(Connection conn) throws SQLException { |
|||
StringBuilder insertStatementBuilder = new StringBuilder(); |
|||
insertStatementBuilder.append("INSERT INTO ").append(this.sqlTableName).append(" ("); |
|||
for (CassandraToSqlColumn column : columns) { |
|||
insertStatementBuilder.append(column.getSqlColumnName()).append(","); |
|||
} |
|||
insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); |
|||
insertStatementBuilder.append(") VALUES ("); |
|||
for (CassandraToSqlColumn ignored : columns) { |
|||
insertStatementBuilder.append("?").append(","); |
|||
} |
|||
insertStatementBuilder.deleteCharAt(insertStatementBuilder.length() - 1); |
|||
insertStatementBuilder.append(")"); |
|||
return conn.prepareStatement(insertStatementBuilder.toString()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
package org.thingsboard.server.service.install.migrate; |
|||
|
|||
public interface EntitiesMigrateService { |
|||
|
|||
void migrate() throws Exception; |
|||
|
|||
} |
|||
@ -0,0 +1,126 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2020 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. |
|||
|
|||
--> |
|||
<form #formContainer class="tb-multiple-input" |
|||
#multipleInputForm="ngForm" |
|||
[formGroup]="multipleInputFormGroup" |
|||
tb-toast toastTarget="{{ toastTargetId }}" |
|||
(ngSubmit)="save()" novalidate autocomplete="off"> |
|||
<div style="padding: 0 8px;" *ngIf="entityDetected && isAllParametersValid"> |
|||
<fieldset *ngFor="let source of sources" [ngClass]="{'fields-group': settings.showGroupTitle}"> |
|||
<legend class="group-title" *ngIf="settings.showGroupTitle">{{ getGroupTitle(source.datasource) }} |
|||
</legend> |
|||
<div fxLayout="row" class="layout-wrap" |
|||
[ngClass]="{'vertical-alignment': isVerticalAlignment || changeAlignment}"> |
|||
<div *ngFor="let key of visibleKeys(source)" |
|||
[ngStyle]="{width: (isVerticalAlignment || changeAlignment) ? '100%' : inputWidthSettings}"> |
|||
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'string'"> |
|||
<mat-form-field class="mat-block"> |
|||
<mat-label>{{key.label}}</mat-label> |
|||
<input matInput |
|||
formControlName="{{key.formId}}" |
|||
[required]="key.settings.required" |
|||
[readonly]="key.settings.isEditable === 'readonly'" |
|||
type="text" |
|||
(focus)="key.isFocused = true; focusInputElement($event)" |
|||
(blur)="key.isFocused = false; inputChanged(source, key)"> |
|||
<mat-icon *ngIf="key.settings.icon" matPrefix>{{key.settings.icon}}</mat-icon> |
|||
<mat-error *ngIf="multipleInputFormGroup.get(key.formId).hasError('required')"> |
|||
{{key.settings.requiredErrorMessage}} |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="input-field" *ngIf="key.settings.dataKeyValueType === 'double' || |
|||
key.settings.dataKeyValueType === 'integer'"> |
|||
<mat-form-field class="mat-block"> |
|||
<mat-label>{{key.label}}</mat-label> |
|||
<input matInput |
|||
formControlName="{{key.formId}}" |
|||
[required]="key.settings.required" |
|||
[readonly]="key.settings.isEditable === 'readonly'" |
|||
type="number" |
|||
step="{{key.settings.step}}" |
|||
(focus)="key.isFocused = true; focusInputElement($event)" |
|||
(blur)="key.isFocused = false; inputChanged(source, key)"> |
|||
<mat-icon *ngIf="key.settings.icon" matPrefix>{{key.settings.icon}}</mat-icon> |
|||
<mat-error *ngIf="multipleInputFormGroup.get(key.formId).hasError('required')"> |
|||
{{key.settings.requiredErrorMessage}} |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="input-field mat-block" *ngIf="key.settings.dataKeyValueType === 'booleanCheckbox'"> |
|||
<mat-checkbox formControlName="{{key.formId}}" |
|||
(change)="inputChanged(source, key)"> |
|||
{{key.label}} |
|||
</mat-checkbox> |
|||
</div> |
|||
<div class="input-field mat-block" *ngIf="key.settings.dataKeyValueType === 'booleanSwitch'"> |
|||
<mat-slide-toggle formControlName="{{key.formId}}" |
|||
(change)="inputChanged(source, key)"> |
|||
{{key.label}} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="input-field mat-block date-time-input" *ngIf="(key.settings.dataKeyValueType === 'dateTime') || |
|||
(key.settings.dataKeyValueType === 'date') || |
|||
(key.settings.dataKeyValueType === 'time')" fxLayout="column"> |
|||
<div fxLayout="row" [ngClass]="{'vertically-aligned': smallWidthContainer}" fxLayoutGap="16px"> |
|||
<mat-form-field> |
|||
<mat-placeholder>{{key.label}}</mat-placeholder> |
|||
<mat-datetimepicker-toggle [for]="datePicker" matPrefix></mat-datetimepicker-toggle> |
|||
<mat-datetimepicker #datePicker type="{{datePickerType(key.settings.dataKeyValueType)}}" |
|||
openOnFocus="true"></mat-datetimepicker> |
|||
<input matInput formControlName="{{key.formId}}" |
|||
[required]="key.settings.required" |
|||
[readonly]="key.settings.isEditable === 'readonly'" |
|||
[matDatetimepicker]="datePicker" |
|||
(focus)="key.isFocused = true;" |
|||
(blur)="key.isFocused = false;" |
|||
(dateChange)="inputChanged(source, key)"> |
|||
<mat-error *ngIf="multipleInputFormGroup.get(key.formId).hasError('required')"> |
|||
{{key.settings.requiredErrorMessage}} |
|||
</mat-error> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</fieldset> |
|||
<div class="mat-padding" fxLayout="row" fxLayoutAlign="end center" |
|||
*ngIf="entityDetected && settings.showActionButtons"> |
|||
<button mat-button color="primary" type="button" |
|||
(click)="discardAll()" style="max-height: 50px; margin-right:20px;" |
|||
[disabled]="!multipleInputForm.dirty"> |
|||
{{ 'action.undo' | translate }} |
|||
</button> |
|||
<button mat-button mat-raised-button color="primary" type="submit" |
|||
style="max-height: 50px; margin-right:20px;" |
|||
[disabled]="!multipleInputForm.dirty || multipleInputForm.invalid"> |
|||
{{ 'action.save' | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<div class="tb-multiple-input__errors" fxLayout="column" fxLayoutAlign="center center" style="height: 100%;" |
|||
*ngIf="!entityDetected || !isAllParametersValid"> |
|||
<div style="text-align: center; font-size: 18px; color: #a0a0a0;" [fxHide]="entityDetected"> |
|||
{{ 'widgets.input-widgets.no-entity-selected' | translate }} |
|||
</div> |
|||
<div style="text-align: center; font-size: 18px; color: #a0a0a0;" |
|||
[fxShow]="entityDetected && !isAllParametersValid"> |
|||
{{ 'widgets.input-widgets.not-allowed-entity' | translate }} |
|||
</div> |
|||
</div> |
|||
</form> |
|||
@ -0,0 +1,75 @@ |
|||
/** |
|||
* Copyright © 2016-2020 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-multiple-input { |
|||
height: 100%; |
|||
overflow-x: hidden; |
|||
overflow-y: auto; |
|||
|
|||
.fields-group { |
|||
padding: 0 8px; |
|||
margin: 10px 0; |
|||
border: 1px groove rgba(0, 0, 0, .25); |
|||
|
|||
legend { |
|||
color: rgba(0, 0, 0, .7); |
|||
} |
|||
} |
|||
|
|||
.input-field { |
|||
padding-right: 10px; |
|||
|
|||
mat-form-field { |
|||
margin-bottom: 5px; |
|||
} |
|||
} |
|||
|
|||
mat-checkbox, |
|||
mat-slide-toggle { |
|||
display: block; |
|||
margin-top: 20px; |
|||
margin-bottom: 16px; |
|||
white-space: normal; |
|||
} |
|||
|
|||
.date-time-input { |
|||
mat-form-field { |
|||
width: 100%; |
|||
margin: 2px 0; |
|||
} |
|||
} |
|||
|
|||
.vertical-alignment { |
|||
flex-direction: column; |
|||
|
|||
mat-checkbox, |
|||
mat-slide-toggle { |
|||
margin-top: 18px; |
|||
} |
|||
|
|||
mat-slide-toggle { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
} |
|||
} |
|||
|
|||
.vertically-aligned { |
|||
flex-direction: column; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,483 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 { Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { Overlay } from '@angular/cdk/overlay'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { DataKey, Datasource, DatasourceData, DatasourceType, WidgetConfig } from '@shared/models/widget.models'; |
|||
import { IWidgetSubscription } from '@core/api/widget-api.models'; |
|||
import { isDefined, isEqual, isUndefined } from '@core/utils'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
import * as _moment from 'moment'; |
|||
import { FormBuilder, FormGroup, NgForm, ValidatorFn, Validators } from '@angular/forms'; |
|||
import { RequestConfig } from '@core/http/http-utils'; |
|||
import { AttributeService } from '@core/http/attribute.service'; |
|||
import { AttributeData, AttributeScope, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; |
|||
import { forkJoin, Observable } from 'rxjs'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
|
|||
type FieldAlignment = 'row' | 'column'; |
|||
|
|||
type MultipleInputWidgetDataKeyType = 'server' | 'shared' | 'timeseries'; |
|||
type MultipleInputWidgetDataKeyValueType = 'string' | 'double' | 'integer' | |
|||
'booleanCheckbox' | 'booleanSwitch' | |
|||
'dateTime' | 'date' | 'time'; |
|||
type MultipleInputWidgetDataKeyEditableType = 'editable' | 'disabled' | 'readonly'; |
|||
|
|||
interface MultipleInputWidgetSettings { |
|||
widgetTitle: string; |
|||
showActionButtons: boolean; |
|||
updateAllValues: boolean; |
|||
showResultMessage: boolean; |
|||
showGroupTitle: boolean; |
|||
groupTitle: string; |
|||
fieldsAlignment: FieldAlignment; |
|||
fieldsInRow: number; |
|||
attributesShared?: boolean; |
|||
} |
|||
|
|||
interface MultipleInputWidgetDataKeySettings { |
|||
dataKeyType: MultipleInputWidgetDataKeyType; |
|||
dataKeyValueType: MultipleInputWidgetDataKeyValueType; |
|||
required: boolean; |
|||
isEditable: MultipleInputWidgetDataKeyEditableType; |
|||
disabledOnDataKey: string; |
|||
dataKeyHidden: boolean; |
|||
step: number; |
|||
requiredErrorMessage: string; |
|||
icon: string; |
|||
inputTypeNumber?: boolean; |
|||
readOnly?: boolean; |
|||
disabledOnCondition?: boolean; |
|||
} |
|||
|
|||
interface MultipleInputWidgetDataKey extends DataKey { |
|||
formId?: string; |
|||
settings: MultipleInputWidgetDataKeySettings; |
|||
isFocused: boolean; |
|||
value?: any; |
|||
} |
|||
|
|||
interface MultipleInputWidgetSource { |
|||
datasource: Datasource; |
|||
keys: MultipleInputWidgetDataKey[]; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-multiple-input-widget ', |
|||
templateUrl: './multiple-input-widget.component.html', |
|||
styleUrls: ['./multiple-input-widget.component.scss'] |
|||
}) |
|||
export class MultipleInputWidgetComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
@ViewChild('formContainer', {static: true}) formContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('multipleInputForm', {static: true}) multipleInputForm: NgForm; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
private formResizeListener: any; |
|||
private settings: MultipleInputWidgetSettings; |
|||
private widgetConfig: WidgetConfig; |
|||
private subscription: IWidgetSubscription; |
|||
private datasources: Array<Datasource>; |
|||
private sources: Array<MultipleInputWidgetSource> = []; |
|||
|
|||
isVerticalAlignment: boolean; |
|||
inputWidthSettings: string; |
|||
changeAlignment: boolean; |
|||
smallWidthContainer: boolean; |
|||
|
|||
entityDetected = false; |
|||
isAllParametersValid = true; |
|||
|
|||
multipleInputFormGroup: FormGroup; |
|||
|
|||
toastTargetId = 'multiple-input-widget' + this.utils.guid(); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private elementRef: ElementRef, |
|||
private ngZone: NgZone, |
|||
private overlay: Overlay, |
|||
private viewContainerRef: ViewContainerRef, |
|||
private utils: UtilsService, |
|||
private fb: FormBuilder, |
|||
private attributeService: AttributeService, |
|||
private translate: TranslateService) { |
|||
super(store); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.multipleInputWidget = this; |
|||
this.settings = this.ctx.settings; |
|||
this.widgetConfig = this.ctx.widgetConfig; |
|||
this.subscription = this.ctx.defaultSubscription; |
|||
this.datasources = this.subscription.datasources; |
|||
this.initializeConfig(); |
|||
this.updateDatasources(); |
|||
this.buildForm(); |
|||
this.ctx.updateWidgetParams(); |
|||
this.formResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.formResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); |
|||
} |
|||
} |
|||
|
|||
private initializeConfig() { |
|||
|
|||
if (this.settings.widgetTitle && this.settings.widgetTitle.length) { |
|||
this.ctx.widgetTitle = this.utils.customTranslation(this.settings.widgetTitle, this.settings.widgetTitle); |
|||
} else { |
|||
this.ctx.widgetTitle = this.ctx.widgetConfig.title; |
|||
} |
|||
|
|||
this.settings.groupTitle = this.settings.groupTitle || '${entityName}'; |
|||
|
|||
// For backward compatibility
|
|||
if (isUndefined(this.settings.showActionButtons)) { |
|||
this.settings.showActionButtons = true; |
|||
} |
|||
if (isUndefined(this.settings.fieldsAlignment)) { |
|||
this.settings.fieldsAlignment = 'row'; |
|||
} |
|||
if (isUndefined(this.settings.fieldsInRow)) { |
|||
this.settings.fieldsInRow = 2; |
|||
} |
|||
// For backward compatibility
|
|||
|
|||
this.isVerticalAlignment = !(this.settings.fieldsAlignment === 'row'); |
|||
|
|||
if (!this.isVerticalAlignment && this.settings.fieldsInRow) { |
|||
this.inputWidthSettings = 100 / this.settings.fieldsInRow + '%'; |
|||
} |
|||
|
|||
this.updateWidgetDisplaying(); |
|||
} |
|||
|
|||
private updateDatasources() { |
|||
if (this.datasources && this.datasources.length) { |
|||
this.entityDetected = true; |
|||
let keyIndex = 0; |
|||
this.datasources.forEach((datasource) => { |
|||
const source: MultipleInputWidgetSource = { |
|||
datasource, |
|||
keys: [] |
|||
}; |
|||
if (datasource.type === DatasourceType.entity) { |
|||
datasource.dataKeys.forEach((dataKey: MultipleInputWidgetDataKey) => { |
|||
if ((datasource.entityType !== EntityType.DEVICE) && (dataKey.settings.dataKeyType === 'shared')) { |
|||
this.isAllParametersValid = false; |
|||
} |
|||
if (dataKey.units) { |
|||
dataKey.label += ' (' + dataKey.units + ')'; |
|||
} |
|||
dataKey.formId = (++keyIndex)+''; |
|||
dataKey.isFocused = false; |
|||
|
|||
// For backward compatibility
|
|||
if (isUndefined(dataKey.settings.dataKeyType)) { |
|||
if (this.settings.attributesShared) { |
|||
dataKey.settings.dataKeyType = 'shared'; |
|||
} else { |
|||
dataKey.settings.dataKeyType = 'server'; |
|||
} |
|||
} |
|||
|
|||
if (isUndefined(dataKey.settings.dataKeyValueType)) { |
|||
if (dataKey.settings.inputTypeNumber) { |
|||
dataKey.settings.dataKeyValueType = 'double'; |
|||
} else { |
|||
dataKey.settings.dataKeyValueType = 'string'; |
|||
} |
|||
} |
|||
|
|||
if (isUndefined(dataKey.settings.isEditable)) { |
|||
if (dataKey.settings.readOnly) { |
|||
dataKey.settings.isEditable = 'readonly'; |
|||
} else { |
|||
dataKey.settings.isEditable = 'editable'; |
|||
} |
|||
} |
|||
// For backward compatibility
|
|||
|
|||
source.keys.push(dataKey); |
|||
}); |
|||
} else { |
|||
this.entityDetected = false; |
|||
} |
|||
this.sources.push(source); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private buildForm() { |
|||
this.multipleInputFormGroup = this.fb.group({}); |
|||
this.sources.forEach((source) => { |
|||
for (const key of this.visibleKeys(source)) { |
|||
const validators: ValidatorFn[] = []; |
|||
if (key.settings.required) { |
|||
validators.push(Validators.required); |
|||
} |
|||
if (key.settings.dataKeyValueType === 'integer') { |
|||
validators.push(Validators.pattern(/^-?[0-9]+$/)); |
|||
} |
|||
const formControl = this.fb.control( |
|||
{ value: key.value, |
|||
disabled: key.settings.isEditable === 'disabled' || key.settings.disabledOnCondition}, |
|||
validators |
|||
); |
|||
this.multipleInputFormGroup.addControl(key.formId, formControl); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private updateWidgetData(data: Array<DatasourceData>) { |
|||
let dataIndex = 0; |
|||
this.sources.forEach((source) => { |
|||
source.keys.forEach((key) => { |
|||
const keyData = data[dataIndex].data; |
|||
if (keyData && keyData.length) { |
|||
let value; |
|||
switch (key.settings.dataKeyValueType) { |
|||
case 'dateTime': |
|||
case 'date': |
|||
value = _moment(keyData[0][1]).toDate(); |
|||
break; |
|||
case 'time': |
|||
value = _moment().startOf('day').add(keyData[0][1], 'ms').toDate(); |
|||
break; |
|||
case 'booleanCheckbox': |
|||
case 'booleanSwitch': |
|||
value = (keyData[0][1] === 'true'); |
|||
break; |
|||
default: |
|||
value = keyData[0][1]; |
|||
} |
|||
key.value = value; |
|||
} |
|||
|
|||
if (key.settings.isEditable === 'editable' && key.settings.disabledOnDataKey) { |
|||
const conditions = data.filter((item) => { |
|||
return source.datasource === item.datasource && item.dataKey.name === key.settings.disabledOnDataKey; |
|||
}); |
|||
if (conditions && conditions.length) { |
|||
if (conditions[0].data.length) { |
|||
if (conditions[0].data[0][1] === 'false') { |
|||
key.settings.disabledOnCondition = true; |
|||
} else { |
|||
key.settings.disabledOnCondition = !conditions[0].data[0][1]; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (!key.settings.dataKeyHidden) { |
|||
if (key.settings.isEditable === 'disabled' || key.settings.disabledOnCondition) { |
|||
this.multipleInputFormGroup.get(key.formId).disable({emitEvent: false}); |
|||
} else { |
|||
this.multipleInputFormGroup.get(key.formId).enable({emitEvent: false}); |
|||
} |
|||
const dirty = this.multipleInputFormGroup.get(key.formId).dirty; |
|||
if (!key.isFocused && !dirty) { |
|||
this.multipleInputFormGroup.get(key.formId).patchValue(key.value, {emitEvent: false}); |
|||
} |
|||
} |
|||
dataIndex++; |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
private updateWidgetDisplaying() { |
|||
this.changeAlignment = (this.ctx.$container && this.ctx.$container[0].offsetWidth < 620); |
|||
this.smallWidthContainer = (this.ctx.$container && this.ctx.$container[0].offsetWidth < 420); |
|||
} |
|||
|
|||
public onDataUpdated() { |
|||
this.ngZone.run(() => { |
|||
this.updateWidgetData(this.subscription.data); |
|||
this.ctx.detectChanges(); |
|||
}); |
|||
} |
|||
|
|||
private resize() { |
|||
this.ngZone.run(() => { |
|||
this.updateWidgetDisplaying(); |
|||
this.ctx.detectChanges(); |
|||
}); |
|||
} |
|||
|
|||
public getGroupTitle(datasource: Datasource): string { |
|||
return this.utils.createLabelFromDatasource(datasource, this.settings.groupTitle); |
|||
} |
|||
|
|||
public visibleKeys(source: MultipleInputWidgetSource): MultipleInputWidgetDataKey[] { |
|||
return source.keys.filter(key => !key.settings.dataKeyHidden); |
|||
} |
|||
|
|||
public datePickerType(keyType: MultipleInputWidgetDataKeyValueType): string { |
|||
switch (keyType) { |
|||
case 'dateTime': |
|||
return 'datetime'; |
|||
case 'date': |
|||
return 'date'; |
|||
case 'time': |
|||
return 'time'; |
|||
} |
|||
} |
|||
|
|||
public focusInputElement($event: Event) { |
|||
($event.target as HTMLInputElement).select(); |
|||
} |
|||
|
|||
public inputChanged(source: MultipleInputWidgetSource, key: MultipleInputWidgetDataKey) { |
|||
if (!this.settings.showActionButtons) { |
|||
const currentValue = this.multipleInputFormGroup.get(key.formId).value; |
|||
if (!key.settings.required || (key.settings.required && isDefined(currentValue))) { |
|||
const dataToSave: MultipleInputWidgetSource = { |
|||
datasource: source.datasource, |
|||
keys: [key] |
|||
}; |
|||
this.save(dataToSave); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public save(dataToSave?: MultipleInputWidgetSource) { |
|||
const config: RequestConfig = { |
|||
ignoreLoading: !this.settings.showActionButtons |
|||
}; |
|||
let data: Array<MultipleInputWidgetSource>; |
|||
if (dataToSave) { |
|||
data = [dataToSave]; |
|||
} else { |
|||
data = this.sources; |
|||
} |
|||
const tasks: Observable<any>[] = []; |
|||
data.forEach((toSave) => { |
|||
const serverAttributes: AttributeData[] = []; |
|||
const sharedAttributes: AttributeData[] = []; |
|||
const telemetry: AttributeData[] = []; |
|||
for (const key of this.visibleKeys(toSave)) { |
|||
const currentValue = this.multipleInputFormGroup.get(key.formId).value; |
|||
if (!isEqual(currentValue, key.value) || this.settings.updateAllValues) { |
|||
const attribute: AttributeData = { |
|||
key: key.name, |
|||
value: null |
|||
}; |
|||
if (currentValue) { |
|||
switch (key.settings.dataKeyValueType) { |
|||
case 'dateTime': |
|||
case 'date': |
|||
attribute.value = currentValue.getTime(); |
|||
break; |
|||
case 'time': |
|||
attribute.value = currentValue.getTime() - _moment().startOf('day').valueOf(); |
|||
break; |
|||
default: |
|||
attribute.value = currentValue; |
|||
} |
|||
} else { |
|||
if (currentValue === '') { |
|||
attribute.value = null; |
|||
} else { |
|||
attribute.value = currentValue; |
|||
} |
|||
} |
|||
|
|||
switch (key.settings.dataKeyType) { |
|||
case 'shared': |
|||
sharedAttributes.push(attribute); |
|||
break; |
|||
case 'timeseries': |
|||
telemetry.push(attribute); |
|||
break; |
|||
default: |
|||
serverAttributes.push(attribute); |
|||
} |
|||
} |
|||
} |
|||
const entityId: EntityId = { |
|||
entityType: toSave.datasource.entityType, |
|||
id: toSave.datasource.entityId |
|||
}; |
|||
if (serverAttributes.length) { |
|||
tasks.push(this.attributeService.saveEntityAttributes( |
|||
entityId, |
|||
AttributeScope.SERVER_SCOPE, |
|||
serverAttributes, |
|||
config |
|||
)); |
|||
} |
|||
if (sharedAttributes.length) { |
|||
tasks.push(this.attributeService.saveEntityAttributes( |
|||
entityId, |
|||
AttributeScope.SHARED_SCOPE, |
|||
sharedAttributes, |
|||
config |
|||
)); |
|||
} |
|||
if (telemetry.length) { |
|||
tasks.push(this.attributeService.saveEntityTimeseries( |
|||
entityId, |
|||
LatestTelemetry.LATEST_TELEMETRY, |
|||
telemetry, |
|||
config |
|||
)); |
|||
} |
|||
}); |
|||
if (tasks.length) { |
|||
forkJoin(tasks).subscribe( |
|||
() => { |
|||
this.multipleInputForm.resetForm(); |
|||
this.multipleInputFormGroup.markAsPristine(); |
|||
if (this.settings.showResultMessage) { |
|||
this.ctx.showSuccessToast(this.translate.instant('widgets.input-widgets.update-successful'), |
|||
1000, 'bottom', 'left', this.toastTargetId); |
|||
} |
|||
}, |
|||
() => { |
|||
if (this.settings.showResultMessage) { |
|||
this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), |
|||
'bottom', 'left', this.toastTargetId); |
|||
} |
|||
}); |
|||
} else { |
|||
this.multipleInputForm.resetForm(); |
|||
this.multipleInputFormGroup.markAsPristine(); |
|||
} |
|||
} |
|||
|
|||
public discardAll() { |
|||
this.multipleInputForm.resetForm(); |
|||
this.sources.forEach((source) => { |
|||
for (const key of this.visibleKeys(source)) { |
|||
this.multipleInputFormGroup.get(key.formId).patchValue(key.value, {emitEvent: false}); |
|||
} |
|||
}); |
|||
this.multipleInputFormGroup.markAsPristine(); |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2020 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. |
|||
|
|||
--> |
|||
<form [formGroup]="jsonFormGroup" (ngSubmit)="add()" style="min-width: 400px;"> |
|||
<mat-toolbar fxLayout="row" color="primary"> |
|||
<h2>{{ (this.data.title ? this.data.title : 'details.edit-json') | translate }}</h2> |
|||
<span fxFlex></span> |
|||
<button mat-button mat-icon-button |
|||
(click)="cancel()" |
|||
type="button"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
</mat-toolbar> |
|||
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async"> |
|||
</mat-progress-bar> |
|||
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> |
|||
<div mat-dialog-content> |
|||
<fieldset [disabled]="isLoading$ | async"> |
|||
<tb-json-object-edit |
|||
formControlName="json" |
|||
label="{{ 'value.json-value' | translate }}" |
|||
validateContent="true" |
|||
[required]="true" |
|||
[fillHeight]="false"> |
|||
</tb-json-object-edit> |
|||
</fieldset> |
|||
</div> |
|||
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center"> |
|||
<span fxFlex></span> |
|||
<button mat-button mat-raised-button color="primary" |
|||
type="submit" |
|||
[disabled]="(isLoading$ | async) || jsonFormGroup.invalid || !jsonFormGroup.dirty"> |
|||
{{ 'action.save' | translate }} |
|||
</button> |
|||
<button mat-button color="primary" |
|||
style="margin-right: 20px;" |
|||
type="button" |
|||
[disabled]="(isLoading$ | async)" |
|||
(click)="cancel()" cdkFocusInitial> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
</div> |
|||
</form> |
|||
@ -0,0 +1,63 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 {Component, Inject, OnInit} from "@angular/core"; |
|||
import {DialogComponent} from "@shared/components/dialog.component"; |
|||
import {Store} from "@ngrx/store"; |
|||
import {AppState} from "@core/core.state"; |
|||
import {Router} from "@angular/router"; |
|||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; |
|||
import {FormBuilder, FormGroup} from "@angular/forms"; |
|||
|
|||
export interface JsonObjectEdittDialogData { |
|||
jsonValue: Object; |
|||
title?: string; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-object-edit-dialog', |
|||
templateUrl: './json-object-edit-dialog.component.html', |
|||
styleUrls: [] |
|||
}) |
|||
export class JsonObjectEditDialogComponent extends DialogComponent<JsonObjectEditDialogComponent, Object> |
|||
implements OnInit { |
|||
|
|||
jsonFormGroup: FormGroup; |
|||
|
|||
submitted = false; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected router: Router, |
|||
@Inject(MAT_DIALOG_DATA) public data: JsonObjectEdittDialogData, |
|||
public dialogRef: MatDialogRef<JsonObjectEditDialogComponent, Object>, |
|||
public fb: FormBuilder) { |
|||
super(store, router, dialogRef); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.jsonFormGroup = this.fb.group({ |
|||
json: [this.data.jsonValue, []] |
|||
}); |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.dialogRef.close(undefined); |
|||
} |
|||
|
|||
add(): void { |
|||
this.dialogRef.close(this.jsonFormGroup.get('json').value); |
|||
} |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 {Directive, ElementRef, forwardRef, HostListener, Renderer2, SkipSelf} from "@angular/core"; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormControl, FormGroupDirective, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, NgForm, |
|||
ValidationErrors, |
|||
Validator |
|||
} from "@angular/forms"; |
|||
import {ErrorStateMatcher} from "@angular/material/core"; |
|||
|
|||
@Directive({ |
|||
selector: '[tb-json-to-string]', |
|||
providers: [{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => TbJsonToStringDirective), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => TbJsonToStringDirective), |
|||
multi: true, |
|||
}, |
|||
{ |
|||
provide: ErrorStateMatcher, |
|||
useExisting: TbJsonToStringDirective |
|||
}] |
|||
}) |
|||
|
|||
export class TbJsonToStringDirective implements ControlValueAccessor, Validator, ErrorStateMatcher { |
|||
private propagateChange = null; |
|||
private parseError: boolean; |
|||
private data: any; |
|||
|
|||
@HostListener('input', ['$event.target.value']) input(newValue: any): void { |
|||
try { |
|||
this.data = JSON.parse(newValue); |
|||
this.parseError = false; |
|||
} catch (e) { |
|||
this.parseError = true; |
|||
} |
|||
|
|||
this.propagateChange(this.data); |
|||
} |
|||
|
|||
constructor(private render: Renderer2, |
|||
private element: ElementRef, |
|||
@SkipSelf() private errorStateMatcher: ErrorStateMatcher) { |
|||
|
|||
} |
|||
|
|||
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { |
|||
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); |
|||
const customErrorState = !!(control && control.invalid && this.parseError); |
|||
return originalErrorState || customErrorState; |
|||
} |
|||
|
|||
validate(c: FormControl): ValidationErrors { |
|||
return (!this.parseError) ? null : { |
|||
invalidJSON: { |
|||
valid: false |
|||
} |
|||
}; |
|||
} |
|||
|
|||
writeValue(obj: any): void { |
|||
if (obj) { |
|||
this.data = obj; |
|||
this.parseError = false; |
|||
this.render.setProperty(this.element.nativeElement, 'value', JSON.stringify(obj)); |
|||
} |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 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 {Pipe, PipeTransform} from '@angular/core'; |
|||
import {isObject, isNumber} from "@core/utils"; |
|||
|
|||
@Pipe({name: 'tbJson'}) |
|||
export class TbJsonPipe implements PipeTransform { |
|||
transform(value: any): string { |
|||
if (isObject(value)) { |
|||
return JSON.stringify(value); |
|||
} else if (isNumber(value)) { |
|||
return value.toString(); |
|||
} |
|||
return value; |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
/* |
|||
* Copyright © 2016-2020 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. |
|||
*/ |
|||
export default angular.module('tbJsonToString', []) |
|||
.directive('tbJsonToString', InputJson) |
|||
.name; |
|||
|
|||
function InputJson() { |
|||
return { |
|||
restrict: 'A', |
|||
require: 'ngModel', |
|||
link: function(scope, element, attr, ngModelCtrl) { |
|||
function into(input) { |
|||
try { |
|||
ngModelCtrl.$setValidity('invalidJSON', true); |
|||
return angular.fromJson(input); |
|||
} catch (e) { |
|||
ngModelCtrl.$setValidity('invalidJSON', false); |
|||
} |
|||
} |
|||
function out(data) { |
|||
try { |
|||
ngModelCtrl.$setValidity('invalidJSON', true); |
|||
return angular.toJson(data); |
|||
} catch (e) { |
|||
ngModelCtrl.$setValidity('invalidJSON', false); |
|||
} |
|||
} |
|||
ngModelCtrl.$parsers.push(into); |
|||
ngModelCtrl.$formatters.push(out); |
|||
} |
|||
}; |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
/* |
|||
* Copyright © 2016-2020 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. |
|||
*/ |
|||
/* eslint-enable import/no-unresolved, import/default */ |
|||
|
|||
import './attribute-dialog-edit-json.scss'; |
|||
|
|||
/*@ngInject*/ |
|||
export default function AttributeDialogEditJsonController($mdDialog, types, jsonValue, readOnly) { |
|||
|
|||
let vm = this; |
|||
vm.json = angular.toJson(jsonValue, 4); |
|||
vm.readOnly = readOnly; |
|||
vm.contentType = types.contentType.JSON.value; |
|||
|
|||
vm.save = () => { |
|||
$mdDialog.hide(angular.fromJson(vm.json)); |
|||
}; |
|||
|
|||
vm.cancel = () => { |
|||
$mdDialog.cancel(); |
|||
}; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
/** |
|||
* Copyright © 2016-2020 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. |
|||
*/ |
|||
.attribute-edit-json-dialog{ |
|||
min-width: 400px; |
|||
} |
|||
|
|||
@media (max-width: 425px){ |
|||
.attribute-edit-json-dialog { |
|||
min-width: 200px; |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2020 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. |
|||
|
|||
--> |
|||
<md-dialog class="attribute-edit-json-dialog"> |
|||
<form name="theForm" ng-submit="vm.save()"> |
|||
<md-toolbar> |
|||
<div class="md-toolbar-tools"> |
|||
<h2>{{ 'details.edit-json' | translate }}</h2> |
|||
<span flex></span> |
|||
<md-button class="md-icon-button" |
|||
ng-click="vm.cancel()" |
|||
aria-label="{{'action.close'|translate}}"> |
|||
<ng-md-icon icon="close" aria-label="Close"></ng-md-icon> |
|||
</md-button> |
|||
</div> |
|||
</md-toolbar> |
|||
<md-dialog-content> |
|||
<div class="md-dialog-content"> |
|||
<tb-json-content |
|||
ng-model="vm.json" |
|||
label="{{ 'value.json-value' | translate }}" |
|||
content-type="vm.contentType" |
|||
validate-content="true" |
|||
hide-error-toast="true" |
|||
fill-height="false" |
|||
ng-readonly="vm.readOnly"> |
|||
</tb-json-content> |
|||
</div> |
|||
</md-dialog-content> |
|||
<md-dialog-actions layout="row" layout-align="end center" class="action-buttons"> |
|||
<md-button ng-disabled="$root.loading || theForm.$invalid || !theForm.$dirty" type="submit" |
|||
class="md-raised md-primary"> |
|||
{{'action.save'|translate}} |
|||
</md-button> |
|||
<md-button id="cancel-btn" ng-disabled="$root.loading" ng-click="vm.cancel()"> |
|||
{{'action.cancel'|translate }} |
|||
</md-button> |
|||
</md-dialog-actions> |
|||
</form> |
|||
</md-dialog> |
|||
Loading…
Reference in new issue