Commit 6ba0b943e88118ee544988b7d8335f4b7aa46246
Committed by
Igor Kulikov
1 parent
82540669
Create new dataKey type entityField (#2282)
* Add support import label * Create new dataKey type entityField * Add translate to entityField
Showing
14 changed files
with
268 additions
and
19 deletions
... | ... | @@ -848,7 +848,50 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device |
848 | 848 | return deferred.promise; |
849 | 849 | } |
850 | 850 | |
851 | + function getEntityFieldKeys (entityType, searchText) { | |
852 | + let entityFieldKeys = []; | |
853 | + let query = searchText.toLowerCase(); | |
854 | + switch(entityType) { | |
855 | + case types.entityType.user: | |
856 | + entityFieldKeys.push(types.entityField.name.keyName); | |
857 | + entityFieldKeys.push(types.entityField.email.keyName); | |
858 | + entityFieldKeys.push(types.entityField.firstName.keyName); | |
859 | + entityFieldKeys.push(types.entityField.lastName.keyName); | |
860 | + break; | |
861 | + case types.entityType.tenant: | |
862 | + case types.entityType.customer: | |
863 | + entityFieldKeys.push(types.entityField.title.keyName); | |
864 | + entityFieldKeys.push(types.entityField.email.keyName); | |
865 | + entityFieldKeys.push(types.entityField.country.keyName); | |
866 | + entityFieldKeys.push(types.entityField.state.keyName); | |
867 | + entityFieldKeys.push(types.entityField.city.keyName); | |
868 | + entityFieldKeys.push(types.entityField.address.keyName); | |
869 | + entityFieldKeys.push(types.entityField.address2.keyName); | |
870 | + entityFieldKeys.push(types.entityField.zip.keyName); | |
871 | + entityFieldKeys.push(types.entityField.phone.keyName); | |
872 | + break; | |
873 | + case types.entityType.entityView: | |
874 | + entityFieldKeys.push(types.entityField.name.keyName); | |
875 | + entityFieldKeys.push(types.entityField.type.keyName); | |
876 | + break; | |
877 | + case types.entityType.device: | |
878 | + case types.entityType.asset: | |
879 | + entityFieldKeys.push(types.entityField.name.keyName); | |
880 | + entityFieldKeys.push(types.entityField.type.keyName); | |
881 | + entityFieldKeys.push(types.entityField.label.keyName); | |
882 | + break; | |
883 | + case types.entityType.dashboard: | |
884 | + entityFieldKeys.push(types.entityField.title.keyName); | |
885 | + break; | |
886 | + } | |
887 | + | |
888 | + return query ? entityFieldKeys.filter((entityField) => entityField.toLowerCase().indexOf(query) === 0) : entityFieldKeys; | |
889 | + } | |
890 | + | |
851 | 891 | function getEntityKeys(entityType, entityId, query, type, config) { |
892 | + if (type === types.dataKeyType.entityField) { | |
893 | + return $q.when(getEntityFieldKeys(entityType, query)); | |
894 | + } | |
852 | 895 | var deferred = $q.defer(); |
853 | 896 | var url = '/api/plugins/telemetry/' + entityType + '/' + entityId + '/keys/'; |
854 | 897 | if (type === types.dataKeyType.timeseries) { | ... | ... |
... | ... | @@ -350,6 +350,11 @@ export default class Subscription { |
350 | 350 | dataKey: dataKey, |
351 | 351 | data: [] |
352 | 352 | }; |
353 | + if (dataKey.type === this.ctx.types.dataKeyType.entityField) { | |
354 | + if(datasource.entity && datasource.entity[this.ctx.types.entityField[dataKey.name].value]){ | |
355 | + datasourceData.data.push([Date.now(), datasource.entity[this.ctx.types.entityField[dataKey.name].value]]); | |
356 | + } | |
357 | + } | |
353 | 358 | this.data.push(datasourceData); |
354 | 359 | this.hiddenData.push({data: []}); |
355 | 360 | if (this.displayLegend) { |
... | ... | @@ -878,8 +883,14 @@ export default class Subscription { |
878 | 883 | }; |
879 | 884 | } |
880 | 885 | |
886 | + var entityFieldKey = false; | |
887 | + | |
881 | 888 | for (var a = 0; a < datasource.dataKeys.length; a++) { |
882 | - this.data[index + a].data = []; | |
889 | + if (datasource.dataKeys[a].type !== this.ctx.types.dataKeyType.entityField) { | |
890 | + this.data[index + a].data = []; | |
891 | + } else { | |
892 | + entityFieldKey = true; | |
893 | + } | |
883 | 894 | } |
884 | 895 | |
885 | 896 | index += datasource.dataKeys.length; |
... | ... | @@ -891,7 +902,7 @@ export default class Subscription { |
891 | 902 | } |
892 | 903 | |
893 | 904 | var forceUpdate = false; |
894 | - if (datasource.unresolvedStateEntity || | |
905 | + if (datasource.unresolvedStateEntity || entityFieldKey || | |
895 | 906 | !datasource.dataKeys.length || |
896 | 907 | (datasource.type === this.ctx.types.datasourceType.entity && !datasource.entityId) |
897 | 908 | ) { | ... | ... |
... | ... | @@ -78,7 +78,8 @@ export default function AppConfig($provide, |
78 | 78 | $mdIconProvider.iconSet('mdi', mdiIconSet); |
79 | 79 | |
80 | 80 | ngMdIconServiceProvider |
81 | - .addShape('alpha-a-circle-outline', '<path d="M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z" />'); | |
81 | + .addShape('alpha-a-circle-outline', '<path d="M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z" />') | |
82 | + .addShape('alpha-e-circle-outline', '<path d="M9,7H15V9H11V11H15V13H11V15H15V17H9V7M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z" />'); | |
82 | 83 | |
83 | 84 | configureTheme(); |
84 | 85 | |
... | ... | @@ -170,4 +171,4 @@ export default function AppConfig($provide, |
170 | 171 | |
171 | 172 | return aliases; |
172 | 173 | } |
173 | -} | |
\ No newline at end of file | ||
174 | +} | ... | ... |
... | ... | @@ -322,7 +322,8 @@ export default angular.module('thingsboard.types', []) |
322 | 322 | timeseries: "timeseries", |
323 | 323 | attribute: "attribute", |
324 | 324 | function: "function", |
325 | - alarm: "alarm" | |
325 | + alarm: "alarm", | |
326 | + entityField: "entityField" | |
326 | 327 | }, |
327 | 328 | contentType: { |
328 | 329 | "JSON": { |
... | ... | @@ -467,6 +468,84 @@ export default angular.module('thingsboard.types', []) |
467 | 468 | list: 'entity.type-current-customer' |
468 | 469 | } |
469 | 470 | }, |
471 | + entityField: { | |
472 | + createdTime: { | |
473 | + keyName: 'createdTime', | |
474 | + name: 'entity-field.created-time', | |
475 | + value: 'createdTime', | |
476 | + time: true | |
477 | + }, | |
478 | + name: { | |
479 | + keyName: 'name', | |
480 | + name: 'entity-field.name', | |
481 | + value: 'name' | |
482 | + }, | |
483 | + type: { | |
484 | + keyName: 'type', | |
485 | + name: 'entity-field.type', | |
486 | + value: 'type' | |
487 | + }, | |
488 | + firstName: { | |
489 | + keyName: 'firstName', | |
490 | + name: 'entity-field.first-name', | |
491 | + value: 'firstName' | |
492 | + }, | |
493 | + lastName: { | |
494 | + keyName: 'lastName', | |
495 | + name: 'entity-field.last-name', | |
496 | + value: 'lastName' | |
497 | + }, | |
498 | + email: { | |
499 | + keyName: 'email', | |
500 | + name: 'entity-field.email', | |
501 | + value: 'email' | |
502 | + }, | |
503 | + title: { | |
504 | + keyName: 'title', | |
505 | + name: 'entity-field.title', | |
506 | + value: 'title' | |
507 | + }, | |
508 | + country: { | |
509 | + keyName: 'country', | |
510 | + name: 'entity-field.country', | |
511 | + value: 'country' | |
512 | + }, | |
513 | + state: { | |
514 | + keyName: 'state', | |
515 | + name: 'entity-field.state', | |
516 | + value: 'state' | |
517 | + }, | |
518 | + city: { | |
519 | + keyName: 'city', | |
520 | + name: 'entity-field.city', | |
521 | + value: 'city' | |
522 | + }, | |
523 | + address: { | |
524 | + keyName: 'address', | |
525 | + name: 'entity-field.address', | |
526 | + value: 'address' | |
527 | + }, | |
528 | + address2: { | |
529 | + keyName: 'address2', | |
530 | + name: 'entity-field.address2', | |
531 | + value: 'address2' | |
532 | + }, | |
533 | + zip: { | |
534 | + keyName: 'zip', | |
535 | + name: 'entity-field.zip', | |
536 | + value: 'zip' | |
537 | + }, | |
538 | + phone: { | |
539 | + keyName: 'phone', | |
540 | + name: 'entity-field.phone', | |
541 | + value: 'phone' | |
542 | + }, | |
543 | + label: { | |
544 | + keyName: 'label', | |
545 | + name: 'entity-field.label', | |
546 | + value: 'label' | |
547 | + } | |
548 | + }, | |
470 | 549 | entitySearchDirection: { |
471 | 550 | from: "FROM", |
472 | 551 | to: "TO" | ... | ... |
... | ... | @@ -16,9 +16,7 @@ |
16 | 16 | |
17 | 17 | --> |
18 | 18 | <md-content class="md-padding" layout="column"> |
19 | - <md-autocomplete ng-if="model.type === types.dataKeyType.timeseries || | |
20 | - model.type === types.dataKeyType.attribute || | |
21 | - model.type === types.dataKeyType.alarm" | |
19 | + <md-autocomplete ng-if="model.type !== types.dataKeyType.function" | |
22 | 20 | style="padding-bottom: 8px;" |
23 | 21 | ng-required="true" |
24 | 22 | md-no-cache="true" |
... | ... | @@ -87,4 +85,4 @@ |
87 | 85 | prevOrigValue - {{ 'datakey.prev-orig-value-description' | translate }} |
88 | 86 | </label> |
89 | 87 | </section> |
90 | -</md-content> | |
\ No newline at end of file | ||
88 | +</md-content> | ... | ... |
... | ... | @@ -124,7 +124,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc |
124 | 124 | var alarmDataKeys = []; |
125 | 125 | for (var d in ngModelCtrl.$viewValue.dataKeys) { |
126 | 126 | var dataKey = ngModelCtrl.$viewValue.dataKeys[d]; |
127 | - if ((dataKey.type === types.dataKeyType.timeseries) || (dataKey.type === types.dataKeyType.attribute)) { | |
127 | + if ((dataKey.type === types.dataKeyType.timeseries) || (dataKey.type === types.dataKeyType.attribute) || (dataKey.type === types.dataKeyType.entityField)) { | |
128 | 128 | dataKeys.push(dataKey); |
129 | 129 | } else if (dataKey.type === types.dataKeyType.alarm) { |
130 | 130 | alarmDataKeys.push(dataKey); |
... | ... | @@ -219,7 +219,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc |
219 | 219 | w.triggerHandler('resize'); |
220 | 220 | } |
221 | 221 | }).then(function (newDataKey) { |
222 | - if ((newDataKey.type === types.dataKeyType.timeseries) || (newDataKey.type === types.dataKeyType.attribute)) { | |
222 | + if ((newDataKey.type === types.dataKeyType.timeseries) || (newDataKey.type === types.dataKeyType.attribute) || (newDataKey.type === types.dataKeyType.entityField)) { | |
223 | 223 | let index = scope.dataKeys.indexOf(dataKey); |
224 | 224 | scope.dataKeys[index] = newDataKey; |
225 | 225 | } else if (newDataKey.type === types.dataKeyType.alarm) { |
... | ... | @@ -246,10 +246,16 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc |
246 | 246 | items.push({ name: dataKeys[i], type: types.dataKeyType.timeseries }); |
247 | 247 | } |
248 | 248 | if (scope.widgetType == types.widgetType.latest.value) { |
249 | - scope.fetchEntityKeys({entityAliasId: scope.entityAlias.id, query: searchText, type: types.dataKeyType.attribute}) | |
250 | - .then(function (dataKeys) { | |
249 | + var keysType = [types.dataKeyType.attribute, types.dataKeyType.entityField]; | |
250 | + var promises = []; | |
251 | + keysType.forEach((type) => { | |
252 | + promises.push(scope.fetchEntityKeys({entityAliasId: scope.entityAlias.id, query: searchText, type: type})); | |
253 | + }); | |
254 | + $q.all(promises).then(function (dataKeys) { | |
251 | 255 | for (var i = 0; i < dataKeys.length; i++) { |
252 | - items.push({ name: dataKeys[i], type: types.dataKeyType.attribute }); | |
256 | + for (var j = 0; j < dataKeys[i].length; j++) { | |
257 | + items.push({name: dataKeys[i][j], type: keysType[i]}); | |
258 | + } | |
253 | 259 | } |
254 | 260 | deferred.resolve(items); |
255 | 261 | }, function (e) { | ... | ... |
... | ... | @@ -43,6 +43,10 @@ |
43 | 43 | <md-tooltip>{{'datakey.attributes' | translate }}</md-tooltip> |
44 | 44 | <ng-md-icon size="16" icon="alpha-a-circle-outline"></ng-md-icon> |
45 | 45 | </span> |
46 | + <span ng-show="item.type==types.dataKeyType.entityField"> | |
47 | + <md-tooltip>{{'datakey.entityField' | translate }}</md-tooltip> | |
48 | + <ng-md-icon size="16" icon="alpha-e-circle-outline"></ng-md-icon> | |
49 | + </span> | |
46 | 50 | <span ng-show="item.type==types.dataKeyType.timeseries"> |
47 | 51 | <md-tooltip>{{'datakey.timeseries' | translate }}</md-tooltip> |
48 | 52 | <ng-md-icon size="16" icon="timeline"></ng-md-icon> |
... | ... | @@ -60,6 +64,10 @@ |
60 | 64 | <md-tooltip>{{'datakey.attributes' | translate }}</md-tooltip> |
61 | 65 | <ng-md-icon size="16" icon="alpha-a-circle-outline" ng-click="createKey($event, types.dataKeyType.attribute, '#datakey_chips')"></ng-md-icon> |
62 | 66 | </span> |
67 | + <span ng-show="widgetType == types.widgetType.latest.value"> | |
68 | + <md-tooltip>{{'datakey.entityField' | translate }}</md-tooltip> | |
69 | + <ng-md-icon size="16" icon="alpha-e-circle-outline" ng-click="createKey($event, types.dataKeyType.entityField, '#datakey_chips')"></ng-md-icon> | |
70 | + </span> | |
63 | 71 | <span> |
64 | 72 | <md-tooltip>{{'datakey.timeseries' | translate }}</md-tooltip> |
65 | 73 | <ng-md-icon size="16" icon="timeline" ng-click="createKey($event, types.dataKeyType.timeseries, '#datakey_chips')"></ng-md-icon> |
... | ... | @@ -82,6 +90,10 @@ |
82 | 90 | <md-tooltip>{{'datakey.attributes' | translate }}</md-tooltip> |
83 | 91 | <ng-md-icon size="20" icon="alpha-a-circle-outline"></ng-md-icon> |
84 | 92 | </span> |
93 | + <span ng-show="$chip.type==types.dataKeyType.entityField"> | |
94 | + <md-tooltip>{{'datakey.entityField' | translate }}</md-tooltip> | |
95 | + <ng-md-icon size="20" icon="alpha-e-circle-outline"></ng-md-icon> | |
96 | + </span> | |
85 | 97 | <span ng-show="$chip.type==types.dataKeyType.timeseries"> |
86 | 98 | <md-tooltip>{{'datakey.timeseries' | translate }}</md-tooltip> |
87 | 99 | <ng-md-icon size="20" icon="timeline"></ng-md-icon> | ... | ... |
... | ... | @@ -423,10 +423,10 @@ function WidgetConfig($compile, $templateCache, $rootScope, $translate, $timeout |
423 | 423 | } |
424 | 424 | |
425 | 425 | var label = chip; |
426 | - if (type === types.dataKeyType.alarm) { | |
427 | - var alarmField = types.alarmFields[chip]; | |
428 | - if (alarmField) { | |
429 | - label = $translate.instant(alarmField.name)+''; | |
426 | + if (type === types.dataKeyType.alarm || type === types.dataKeyType.entityField) { | |
427 | + var keyField = type === types.dataKeyType.alarm ? types.alarmFields[chip] : types.entityField[chip]; | |
428 | + if (keyField) { | |
429 | + label = $translate.instant(keyField.name)+''; | |
430 | 430 | } |
431 | 431 | } |
432 | 432 | label = scope.genNextLabel(label); | ... | ... |
... | ... | @@ -1102,6 +1102,22 @@ |
1102 | 1102 | "copyId": "Αντιγραφή ID ομάδας οντοτήτων", |
1103 | 1103 | "idCopiedMessage": "Το ID της ομάδας οντοτήτων έχει αντιγραφεί στο πρόχειρο" |
1104 | 1104 | }, |
1105 | + "entity-field": { | |
1106 | + "created-time": "Δημιουργήθηκε", | |
1107 | + "name": "Όνομα", | |
1108 | + "type": "Τύπος", | |
1109 | + "first-name": "Όνομα", | |
1110 | + "last-name": "Επίθετο", | |
1111 | + "email": "Email", | |
1112 | + "title": "Τίτλος", | |
1113 | + "country": "Χώρα", | |
1114 | + "state": "Νομός", | |
1115 | + "city": "Πόλη", | |
1116 | + "address": "Διεύθυνση", | |
1117 | + "address2": "Διεύθυνση 2", | |
1118 | + "zip": "Τ.Κ.", | |
1119 | + "phone": "Τηλέφωνο" | |
1120 | + }, | |
1105 | 1121 | "entity-view": { |
1106 | 1122 | "entity-view": "Όψη Οντότητας", |
1107 | 1123 | "entity-view-required": "Απαιτείται προβολή οντότητας.", |
... | ... | @@ -2603,4 +2619,4 @@ |
2603 | 2619 | "el_GR": "Ελληνικά" |
2604 | 2620 | } |
2605 | 2621 | } |
2606 | -} | |
\ No newline at end of file | ||
2622 | +} | ... | ... |
... | ... | @@ -815,6 +815,23 @@ |
815 | 815 | "no-data": "No data to display", |
816 | 816 | "columns-to-display": "Columns to Display" |
817 | 817 | }, |
818 | + "entity-field": { | |
819 | + "created-time": "Created time", | |
820 | + "name": "Name", | |
821 | + "type": "Type", | |
822 | + "first-name": "First name", | |
823 | + "last-name": "Last name", | |
824 | + "email": "Email", | |
825 | + "title": "Title", | |
826 | + "country": "Country", | |
827 | + "state": "State", | |
828 | + "city": "City", | |
829 | + "address": "Address", | |
830 | + "address2": "Address 2", | |
831 | + "zip": "Zip", | |
832 | + "phone": "Phone", | |
833 | + "label": "Label" | |
834 | + }, | |
818 | 835 | "entity-view": { |
819 | 836 | "entity-view": "Entity View", |
820 | 837 | "entity-view-required": "Entity view is required.", | ... | ... |
... | ... | @@ -808,6 +808,22 @@ |
808 | 808 | "no-data": "No hay datos para mostrar", |
809 | 809 | "columns-to-display": "Columnas a mostrar" |
810 | 810 | }, |
811 | + "entity-field": { | |
812 | + "created-time": "Tiempo de creación", | |
813 | + "name": "Nombre", | |
814 | + "type": "Tipo", | |
815 | + "first-name": "Nombre", | |
816 | + "last-name": "Apellido", | |
817 | + "email": "Correo electrónico", | |
818 | + "title": "Título", | |
819 | + "country": "País", | |
820 | + "state": "Estado", | |
821 | + "city": "Ciudad", | |
822 | + "address": "Dirección", | |
823 | + "address2": "Dirección 2", | |
824 | + "zip": "Código postal", | |
825 | + "phone": "Teléfono" | |
826 | + }, | |
811 | 827 | "entity-view": { |
812 | 828 | "entity-view": "Vista de entidad", |
813 | 829 | "entity-view-required": "Vista de entidad es requerido.", | ... | ... |
... | ... | @@ -809,6 +809,22 @@ |
809 | 809 | "use-entity-name-filter": "Utiliser un filtre", |
810 | 810 | "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" |
811 | 811 | }, |
812 | + "entity-field": { | |
813 | + "address": "Adresse", | |
814 | + "address2": "Adresse 2", | |
815 | + "city": "Ville", | |
816 | + "country": "Pays", | |
817 | + "created-time": "Heure de création", | |
818 | + "email": "Email", | |
819 | + "first-name": "Prénom", | |
820 | + "last-name": "Nom de famille", | |
821 | + "name": "Nom", | |
822 | + "phone": "Téléphone", | |
823 | + "state": "Prov", | |
824 | + "title": "Titre", | |
825 | + "type": "Type", | |
826 | + "zip": "Code postal" | |
827 | + }, | |
812 | 828 | "entity-view": { |
813 | 829 | "add": "Ajouter une vue d'entité", |
814 | 830 | "add-alias": "Ajouter un alias de vue d'entité", | ... | ... |
... | ... | @@ -814,6 +814,23 @@ |
814 | 814 | "no-data": "Нет данных для отображения", |
815 | 815 | "columns-to-display": "Отобразить следующие колонки" |
816 | 816 | }, |
817 | + "entity-field": { | |
818 | + "created-time": "Время создания", | |
819 | + "name": "Название", | |
820 | + "type": "Тип", | |
821 | + "first-name": "Имя", | |
822 | + "last-name": "Фамилия", | |
823 | + "email": "Электронная почта", | |
824 | + "title": "Название", | |
825 | + "country": "Страна", | |
826 | + "state": "Штат/Область", | |
827 | + "city": "Город", | |
828 | + "address": "Адрес", | |
829 | + "address2": "Адрес 2", | |
830 | + "zip": "Индекс", | |
831 | + "phone": "Телефон", | |
832 | + "label": "Метка" | |
833 | + }, | |
817 | 834 | "entity-view": { |
818 | 835 | "entity-view": "Представление Объекта", |
819 | 836 | "entity-view-required": "Представление объекта обязательно.", | ... | ... |
... | ... | @@ -956,6 +956,23 @@ |
956 | 956 | "list-of-integrations": "{ count, plural, 1 {Одна інтеграція} other {Список # інтеграцій} }", |
957 | 957 | "integration-name-starts-with": "Інтеграції, імена яких починаються з '{{prefix}}'" |
958 | 958 | }, |
959 | + "entity-field": { | |
960 | + "created-time": "Час створення", | |
961 | + "name": "Ім'я", | |
962 | + "type": "Тип", | |
963 | + "first-name": "Ім'я", | |
964 | + "last-name": "Прізвище", | |
965 | + "email": "Електронна пошта", | |
966 | + "title": "Назва", | |
967 | + "country": "Країна", | |
968 | + "state": "Штат", | |
969 | + "city": "Місто", | |
970 | + "address": "Адреса", | |
971 | + "address2": "Адреса 2", | |
972 | + "zip": "Zip", | |
973 | + "phone": "Телефон", | |
974 | + "label": "Мітка" | |
975 | + }, | |
959 | 976 | "entity-group": { |
960 | 977 | "entity-group": "Група сутності", |
961 | 978 | "details": "Деталі", | ... | ... |