From 08e8c92970519a41d673f38f0bd7ffaa6ff470da Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 12 Aug 2019 19:34:23 +0300 Subject: [PATCH] Tenants and tenant admins pages. --- ui-ngx/src/app/core/http/admin.service.ts | 55 +++ ui-ngx/src/app/core/http/customer.service.ts | 52 +++ ui-ngx/src/app/core/http/dashboard.service.ts | 77 ++++ ui-ngx/src/app/core/http/tenant.service.ts | 50 +++ ui-ngx/src/app/core/services/menu.service.ts | 69 +++- .../translate/translate-default-compiler.ts | 10 +- .../home/menu/side-menu.component.scss | 2 +- .../home/pages/admin/admin-routing.module.ts | 89 +++++ .../modules/home/pages/admin/admin.module.ts | 39 ++ .../admin/general-settings.component.html | 47 +++ .../admin/general-settings.component.scss | 18 + .../pages/admin/general-settings.component.ts | 77 ++++ .../pages/admin/mail-server.component.html | 104 +++++ .../pages/admin/mail-server.component.scss | 18 + .../home/pages/admin/mail-server.component.ts | 101 +++++ .../admin/security-settings.component.html | 119 ++++++ .../admin/security-settings.component.scss | 23 ++ .../admin/security-settings.component.ts | 86 +++++ .../home/pages/admin/settings-card.scss | 25 ++ .../modules/home/pages/home-pages.module.ts | 14 +- .../change-password-dialog.component.html | 63 +++ .../change-password-dialog.component.scss | 17 + .../change-password-dialog.component.ts | 68 ++++ .../pages/profile/profile-routing.module.ts | 69 ++++ .../home/pages/profile/profile.component.html | 77 ++++ .../home/pages/profile/profile.component.scss | 32 ++ .../home/pages/profile/profile.component.ts | 123 ++++++ .../home/pages/profile/profile.module.ts | 38 ++ .../pages/tenant/tenant-routing.module.ts | 80 ++++ .../home/pages/tenant/tenant.component.html | 61 +++ .../home/pages/tenant/tenant.component.ts | 76 ++++ .../home/pages/tenant/tenant.module.ts | 36 ++ .../tenant/tenants-table-config.resolver.ts | 105 +++++ .../activation-link-dialog.component.html | 59 +++ .../user/activation-link-dialog.component.ts | 64 ++++ .../pages/user/add-user-dialog.component.html | 59 +++ .../pages/user/add-user-dialog.component.ts | 114 ++++++ .../home/pages/user/user-routing.module.ts | 28 ++ .../home/pages/user/user.component.html | 90 +++++ .../home/pages/user/user.component.scss | 35 ++ .../modules/home/pages/user/user.component.ts | 82 ++++ .../modules/home/pages/user/user.module.ts | 42 ++ .../pages/user/users-table-config.resolver.ts | 230 +++++++++++ .../shared/components/contact.component.html | 63 +++ .../shared/components/contact.component.ts | 34 ++ .../app/shared/components/contact.models.ts | 291 ++++++++++++++ .../dashboard-autocomplete.component.html | 46 +++ .../dashboard-autocomplete.component.ts | 226 +++++++++++ .../components/details-panel.component.html | 56 +++ .../components/details-panel.component.scss | 53 +++ .../components/details-panel.component.ts | 80 ++++ .../entity/add-entity-dialog.component.html | 51 +++ .../entity/add-entity-dialog.component.scss | 17 + .../entity/add-entity-dialog.component.ts | 103 +++++ .../entity/contact-based.component.ts | 84 ++++ .../entity/entities-table-config.models.ts | 137 +++++++ .../entity/entities-table.component.html | 182 +++++++++ .../entity/entities-table.component.scss | 41 ++ .../entity/entities-table.component.ts | 358 ++++++++++++++++++ .../entity/entity-component.models.ts | 28 ++ .../entity-details-panel.component.html | 43 +++ .../entity-details-panel.component.scss | 31 ++ .../entity/entity-details-panel.component.ts | 165 ++++++++ .../entity/entity-table-header.component.ts | 36 ++ .../components/entity/entity.component.ts | 110 ++++++ .../time/datetime-period.component.html | 47 +++ .../time/datetime-period.component.scss | 26 ++ .../time/datetime-period.component.ts | 137 +++++++ .../time/timeinterval.component.html | 54 +++ .../time/timeinterval.component.scss | 43 +++ .../components/time/timeinterval.component.ts | 277 ++++++++++++++ .../time/timewindow-panel.component.html | 127 +++++++ .../time/timewindow-panel.component.scss | 63 +++ .../time/timewindow-panel.component.ts | 203 ++++++++++ .../components/time/timewindow.component.html | 43 +++ .../components/time/timewindow.component.scss | 26 ++ .../components/time/timewindow.component.ts | 275 ++++++++++++++ .../src/app/shared/models/customer.model.ts | 6 + .../src/app/shared/models/dashboard.models.ts | 35 ++ .../models/datasource/entity-datasource.ts | 113 ++++++ .../src/app/shared/models/id/dashboard-id.ts | 26 ++ .../src/app/shared/models/settings.models.ts | 17 + .../src/app/shared/pipe/enum-to-array.pipe.ts | 27 ++ ui-ngx/src/app/shared/pipe/highlight.pipe.ts | 28 ++ .../pipe/milliseconds-to-time-string.pipe.ts | 58 +++ ui-ngx/src/app/shared/shared.module.ts | 84 ++-- .../assets/locale/locale.constant-en_US.json | 3 + .../assets/locale/locale.constant-es_ES.json | 2 +- .../assets/locale/locale.constant-fr_FR.json | 130 +++---- .../assets/locale/locale.constant-ru_RU.json | 6 +- .../assets/locale/locale.constant-tr_TR.json | 26 +- .../assets/locale/locale.constant-uk_UA.json | 6 +- .../assets/locale/locale.constant-zh_CN.json | 16 +- 93 files changed, 6717 insertions(+), 145 deletions(-) create mode 100644 ui-ngx/src/app/core/http/admin.service.ts create mode 100644 ui-ngx/src/app/core/http/customer.service.ts create mode 100644 ui-ngx/src/app/core/http/dashboard.service.ts create mode 100644 ui-ngx/src/app/core/http/tenant.service.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/admin.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/settings-card.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/profile.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/user.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts create mode 100644 ui-ngx/src/app/shared/components/contact.component.html create mode 100644 ui-ngx/src/app/shared/components/contact.component.ts create mode 100644 ui-ngx/src/app/shared/components/contact.models.ts create mode 100644 ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts create mode 100644 ui-ngx/src/app/shared/components/details-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/details-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/details-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/contact-based.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entities-table.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entities-table.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entities-table.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-component.models.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-details-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-details-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-details-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-table-header.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.html create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/datetime-period.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timeinterval.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.html create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.scss create mode 100644 ui-ngx/src/app/shared/components/time/timewindow.component.ts create mode 100644 ui-ngx/src/app/shared/models/dashboard.models.ts create mode 100644 ui-ngx/src/app/shared/models/datasource/entity-datasource.ts create mode 100644 ui-ngx/src/app/shared/models/id/dashboard-id.ts create mode 100644 ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/highlight.pipe.ts create mode 100644 ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts diff --git a/ui-ngx/src/app/core/http/admin.service.ts b/ui-ngx/src/app/core/http/admin.service.ts new file mode 100644 index 0000000000..589fc4b45f --- /dev/null +++ b/ui-ngx/src/app/core/http/admin.service.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; +import { defaultHttpOptions } from './http-utils'; +import { Observable } from 'rxjs/index'; +import { HttpClient } from '@angular/common/http'; +import {AdminSettings, MailServerSettings, SecuritySettings} from '@shared/models/settings.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AdminService { + + constructor( + private http: HttpClient + ) { } + + public getAdminSettings(key: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/admin/settings/${key}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveAdminSettings(adminSettings: AdminSettings, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.post>('/api/admin/settings', adminSettings, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public sendTestMail(adminSettings: AdminSettings, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.post('/api/admin/settings/testMail', adminSettings, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getSecuritySettings(ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/admin/securitySettings`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveSecuritySettings(securitySettings: SecuritySettings, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.post('/api/admin/securitySettings', securitySettings, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } +} diff --git a/ui-ngx/src/app/core/http/customer.service.ts b/ui-ngx/src/app/core/http/customer.service.ts new file mode 100644 index 0000000000..c84502cc38 --- /dev/null +++ b/ui-ngx/src/app/core/http/customer.service.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; +import { defaultHttpOptions } from './http-utils'; +import { Observable } from 'rxjs/index'; +import { HttpClient } from '@angular/common/http'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; +import { Customer } from '@shared/models/customer.model'; + +@Injectable({ + providedIn: 'root' +}) +export class CustomerService { + + constructor( + private http: HttpClient + ) { } + + public getCustomers(tenantId: string, pageLink: PageLink, ignoreErrors: boolean = false, + ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/tenant/${tenantId}/customers${pageLink.toQuery()}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getCustomer(customerId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/customer/${customerId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveCustomer(customer: Customer, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.post('/api/customer', customer, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public deleteCustomer(customerId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false) { + return this.http.delete(`/api/customer/${customerId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + +} diff --git a/ui-ngx/src/app/core/http/dashboard.service.ts b/ui-ngx/src/app/core/http/dashboard.service.ts new file mode 100644 index 0000000000..3419f6ad8a --- /dev/null +++ b/ui-ngx/src/app/core/http/dashboard.service.ts @@ -0,0 +1,77 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; +import { defaultHttpOptions } from './http-utils'; +import { Observable } from 'rxjs/index'; +import { HttpClient } from '@angular/common/http'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; +import { Tenant } from '@shared/models/tenant.model'; +import {DashboardInfo, Dashboard} from '@shared/models/dashboard.models'; +import {map} from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class DashboardService { + + constructor( + private http: HttpClient + ) { } + + public getTenantDashboards(pageLink: PageLink, ignoreErrors: boolean = false, + ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/tenant/dashboards${pageLink.toQuery()}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getTenantDashboardsByTenantId(tenantId: string, pageLink: PageLink, ignoreErrors: boolean = false, + ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/tenant/${tenantId}/dashboards${pageLink.toQuery()}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getCustomerDashboards(customerId: string, pageLink: PageLink, ignoreErrors: boolean = false, + ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/customer/${customerId}/dashboards${pageLink.toQuery()}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)).pipe( + map( dashboards => { + dashboards.data = dashboards.data.filter(dashboard => { + return dashboard.title.toUpperCase().includes(pageLink.textSearch.toUpperCase()); + }); + return dashboards; + } + )); + } + + public getDashboard(dashboardId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/dashboard/${dashboardId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getDashboardInfo(dashboardId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/dashboard/info/${dashboardId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveDashboard(dashboard: Dashboard, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.post('/api/dashboard', dashboard, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public deleteDashboard(dashboardId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false) { + return this.http.delete(`/api/dashboard/${dashboardId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + +} diff --git a/ui-ngx/src/app/core/http/tenant.service.ts b/ui-ngx/src/app/core/http/tenant.service.ts new file mode 100644 index 0000000000..7574720ca0 --- /dev/null +++ b/ui-ngx/src/app/core/http/tenant.service.ts @@ -0,0 +1,50 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; +import { defaultHttpOptions } from './http-utils'; +import { Observable } from 'rxjs/index'; +import { HttpClient } from '@angular/common/http'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; +import { Tenant } from '@shared/models/tenant.model'; + +@Injectable({ + providedIn: 'root' +}) +export class TenantService { + + constructor( + private http: HttpClient + ) { } + + public getTenants(pageLink: PageLink, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/tenants${pageLink.toQuery()}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getTenant(tenantId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/tenant/${tenantId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveTenant(tenant: Tenant, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.post('/api/tenant', tenant, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public deleteTenant(tenantId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false) { + return this.http.delete(`/api/tenant/${tenantId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + +} diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 463513052e..817d024f50 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -320,15 +320,78 @@ export class MenuService { type: 'link', path: '/home', icon: 'home' + }, + { + name: 'asset.assets', + type: 'link', + path: '/assets', + icon: 'domain' + }, + { + name: 'device.devices', + type: 'link', + path: '/devices', + icon: 'devices_other' + }, + { + name: 'entity-view.entity-views', + type: 'link', + path: '/entityViews', + icon: 'view_quilt' + }, + { + name: 'dashboard.dashboards', + type: 'link', + path: '/dashboards', + icon: 'dashboard' } ); - // TODO: return sections; } private buildCustomerUserHome(authUser: any): Array { - const homeSections: Array = []; - // TODO: + const homeSections: Array = [ + { + name: 'asset.view-assets', + places: [ + { + name: 'asset.assets', + icon: 'domain', + path: '/assets' + } + ] + }, + { + name: 'device.view-devices', + places: [ + { + name: 'device.devices', + icon: 'devices_other', + path: '/devices' + } + ] + }, + { + name: 'entity-view.management', + places: [ + { + name: 'entity-view.entity-views', + icon: 'view_quilt', + path: '/entityViews' + } + ] + }, + { + name: 'dashboard.view-dashboards', + places: [ + { + name: 'dashboard.dashboards', + icon: 'dashboard', + path: '/dashboards' + } + ] + } + ]; return homeSections; } diff --git a/ui-ngx/src/app/core/translate/translate-default-compiler.ts b/ui-ngx/src/app/core/translate/translate-default-compiler.ts index 3e92f3b64c..5e30a0b588 100644 --- a/ui-ngx/src/app/core/translate/translate-default-compiler.ts +++ b/ui-ngx/src/app/core/translate/translate-default-compiler.ts @@ -56,8 +56,14 @@ export class TranslateDefaultCompiler extends TranslateMessageFormatCompiler { } private checkIsPlural(src: string): boolean { - const tokens: any[] = parse(src.replace(/\{\{/g, '{').replace(/\}\}/g, '}'), - {cardinal: [], ordinal: []}); + let tokens: any[]; + try { + tokens = parse(src.replace(/\{\{/g, '{').replace(/\}\}/g, '}'), + {cardinal: [], ordinal: []}); + } catch (e) { + console.warn(`Failed to parse source: ${src}`); + console.error(e); + } const res = tokens.filter( (value) => typeof value !== 'string' && value.type === 'plural' ); diff --git a/ui-ngx/src/app/modules/home/menu/side-menu.component.scss b/ui-ngx/src/app/modules/home/menu/side-menu.component.scss index 53100c456e..09f4463029 100644 --- a/ui-ngx/src/app/modules/home/menu/side-menu.component.scss +++ b/ui-ngx/src/app/modules/home/menu/side-menu.component.scss @@ -106,7 +106,7 @@ button { padding: 0 16px 0 32px; font-weight: 500; - text-transform: none; + text-transform: none !important; text-rendering: optimizeLegibility; } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts new file mode 100644 index 0000000000..0b36672d72 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import {GeneralSettingsComponent} from "@modules/home/pages/admin/general-settings.component"; +import {SecuritySettingsComponent} from "@modules/home/pages/admin/security-settings.component"; + +const routes: Routes = [ + { + path: 'settings', + data: { + auth: [Authority.SYS_ADMIN], + breadcrumb: { + label: 'admin.system-settings', + icon: 'settings' + } + }, + children: [ + { + path: '', + redirectTo: 'general', + pathMatch: 'full' + }, + { + path: 'general', + component: GeneralSettingsComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN], + title: 'admin.general-settings', + breadcrumb: { + label: 'admin.general', + icon: 'settings_applications' + } + } + }, + { + path: 'outgoing-mail', + component: MailServerComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN], + title: 'admin.outgoing-mail-settings', + breadcrumb: { + label: 'admin.outgoing-mail', + icon: 'mail' + } + } + }, + { + path: 'security-settings', + component: SecuritySettingsComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN], + title: 'admin.security-settings', + breadcrumb: { + label: 'admin.security-settings', + icon: 'security' + } + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AdminRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts new file mode 100644 index 0000000000..18dc45c68c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -0,0 +1,39 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { AdminRoutingModule } from './admin-routing.module'; +import { SharedModule } from '@app/shared/shared.module'; +import { MailServerComponent } from '@modules/home/pages/admin/mail-server.component'; +import {GeneralSettingsComponent} from "@modules/home/pages/admin/general-settings.component"; +import {SecuritySettingsComponent} from "@modules/home/pages/admin/security-settings.component"; + +@NgModule({ + declarations: + [ + GeneralSettingsComponent, + MailServerComponent, + SecuritySettingsComponent + ], + imports: [ + CommonModule, + SharedModule, + AdminRoutingModule + ] +}) +export class AdminModule { } diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html new file mode 100644 index 0000000000..aa6ea9421a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html @@ -0,0 +1,47 @@ + +
+ + +
+ admin.general-settings +
+
+ + +
+ +
+
+ + admin.base-url + + + {{ 'admin.base-url-required' | translate }} + + +
+ +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss new file mode 100644 index 0000000000..dfbd362f33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2019 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 { + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts new file mode 100644 index 0000000000..a7d7264115 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts @@ -0,0 +1,77 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { Router } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import {AdminSettings, GeneralSettings} from '@shared/models/settings.models'; +import { AdminService } from '@core/http/admin.service'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; + +@Component({ + selector: 'tb-general-settings', + templateUrl: './general-settings.component.html', + styleUrls: ['./general-settings.component.scss', './settings-card.scss'] +}) +export class GeneralSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { + + generalSettings: FormGroup; + adminSettings: AdminSettings; + + constructor(protected store: Store, + private router: Router, + private adminService: AdminService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.buildGeneralServerSettingsForm(); + this.adminService.getAdminSettings('general').subscribe( + (adminSettings) => { + this.adminSettings = adminSettings; + this.generalSettings.reset(this.adminSettings.jsonValue); + } + ); + } + + buildGeneralServerSettingsForm() { + this.generalSettings = this.fb.group({ + baseUrl: ['', [Validators.required]] + }); + } + + save(): void { + this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.generalSettings.value}; + this.adminService.saveAdminSettings(this.adminSettings).subscribe( + (adminSettings) => { + this.adminSettings = adminSettings; + this.generalSettings.reset(this.adminSettings.jsonValue); + } + ); + } + + confirmForm(): FormGroup { + return this.generalSettings; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html new file mode 100644 index 0000000000..88fe171c96 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html @@ -0,0 +1,104 @@ + +
+ + +
+ admin.outgoing-mail-settings + +
+
+
+ + +
+ +
+
+ + admin.mail-from + + + {{ 'admin.mail-from-required' | translate }} + + + + admin.smtp-protocol + + + {{protocol.toUpperCase()}} + + + +
+ + admin.smtp-host + + + {{ 'admin.smtp-host-required' | translate }} + + + + admin.smtp-port + + {{smtpPortInput.value?.length || 0}}/5 + + {{ 'admin.smtp-port-required' | translate }} + + + {{ 'admin.smtp-port-invalid' | translate }} + + +
+ + admin.timeout-msec + + {{timeoutInput.value?.length || 0}}/6 + + {{ 'admin.timeout-required' | translate }} + + + {{ 'admin.timeout-invalid' | translate }} + + + + {{ 'admin.enable-tls' | translate }} + + + common.username + + + + common.password + + +
+ + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss new file mode 100644 index 0000000000..dfbd362f33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2019 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 { + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts new file mode 100644 index 0000000000..e661cb1a78 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { Router } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { AdminSettings, MailServerSettings, smtpPortPattern } from '@shared/models/settings.models'; +import { AdminService } from '@core/http/admin.service'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; + +@Component({ + selector: 'tb-mail-server', + templateUrl: './mail-server.component.html', + styleUrls: ['./mail-server.component.scss', './settings-card.scss'] +}) +export class MailServerComponent extends PageComponent implements OnInit, HasConfirmForm { + + mailSettings: FormGroup; + adminSettings: AdminSettings; + smtpProtocols = ['smtp', 'smtps']; + + constructor(protected store: Store, + private router: Router, + private adminService: AdminService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.buildMailServerSettingsForm(); + this.adminService.getAdminSettings('mail').subscribe( + (adminSettings) => { + this.adminSettings = adminSettings; + this.mailSettings.reset(this.adminSettings.jsonValue); + } + ); + } + + buildMailServerSettingsForm() { + this.mailSettings = this.fb.group({ + mailFrom: ['', [Validators.required]], + smtpProtocol: ['smtp'], + smtpHost: ['localhost', [Validators.required]], + smtpPort: ['25', [Validators.required, + Validators.pattern(smtpPortPattern), + Validators.maxLength(5)]], + timeout: ['10000', [Validators.required, + Validators.pattern(/^[0-9]{1,6}$/), + Validators.maxLength(6)]], + enableTls: ['false'], + username: [''], + password: [''] + }); + this.registerDisableOnLoadFormControl(this.mailSettings.get('smtpProtocol')); + this.registerDisableOnLoadFormControl(this.mailSettings.get('enableTls')); + } + + sendTestMail(): void { + this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.mailSettings.value}; + this.adminService.sendTestMail(this.adminSettings).subscribe( + () => { + this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('admin.test-mail-sent'), + type: 'success' })); + } + ); + } + + save(): void { + this.adminSettings.jsonValue = {...this.adminSettings.jsonValue, ...this.mailSettings.value}; + this.adminService.saveAdminSettings(this.adminSettings).subscribe( + (adminSettings) => { + this.adminSettings = adminSettings; + this.mailSettings.reset(this.adminSettings.jsonValue); + } + ); + } + + confirmForm(): FormGroup { + return this.mailSettings; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html new file mode 100644 index 0000000000..1c925b0f64 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html @@ -0,0 +1,119 @@ + +
+ + +
+ admin.security-settings + +
+
+
+ + +
+ +
+
+ + + +
admin.password-policy
+
+
+
+ + admin.minimum-password-length + + + {{ 'admin.minimum-password-length-required' | translate }} + + + {{ 'admin.minimum-password-length-range' | translate }} + + + {{ 'admin.minimum-password-length-range' | translate }} + + + + admin.minimum-uppercase-letters + + + {{ 'admin.minimum-uppercase-letters-range' | translate }} + + + + admin.minimum-lowercase-letters + + + {{ 'admin.minimum-lowercase-letters-range' | translate }} + + + + admin.minimum-digits + + + {{ 'admin.minimum-digits-range' | translate }} + + + + admin.minimum-special-characters + + + {{ 'admin.minimum-special-characters-range' | translate }} + + + + admin.password-expiration-period-days + + + {{ 'admin.password-expiration-period-days-range' | translate }} + + +
+
+
+ +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss new file mode 100644 index 0000000000..e686efdff5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2019 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 { + mat-expansion-panel { + margin-bottom: 16px; + } + .tb-panel-title { + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts new file mode 100644 index 0000000000..fbd83506c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts @@ -0,0 +1,86 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { Router } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { SecuritySettings} from '@shared/models/settings.models'; +import { AdminService } from '@core/http/admin.service'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; + +@Component({ + selector: 'tb-security-settings', + templateUrl: './security-settings.component.html', + styleUrls: ['./security-settings.component.scss', './settings-card.scss'] +}) +export class SecuritySettingsComponent extends PageComponent implements OnInit, HasConfirmForm { + + securitySettingsFormGroup: FormGroup; + securitySettings: SecuritySettings; + + constructor(protected store: Store, + private router: Router, + private adminService: AdminService, + private translate: TranslateService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.buildSecuritySettingsForm(); + this.adminService.getSecuritySettings().subscribe( + (securitySettings) => { + this.securitySettings = securitySettings; + this.securitySettingsFormGroup.reset(this.securitySettings); + } + ); + } + + buildSecuritySettingsForm() { + this.securitySettingsFormGroup = this.fb.group({ + passwordPolicy: this.fb.group( + { + minimumLength: [null, [Validators.required, Validators.min(5), Validators.max(50)]], + minimumUppercaseLetters: [null, Validators.min(0)], + minimumLowercaseLetters: [null, Validators.min(0)], + minimumDigits: [null, Validators.min(0)], + minimumSpecialCharacters: [null, Validators.min(0)], + passwordExpirationPeriodDays: [null, Validators.min(0)] + } + ) + }); + } + + save(): void { + this.securitySettings = {...this.securitySettings, ...this.securitySettingsFormGroup.value}; + this.adminService.saveSecuritySettings(this.securitySettings).subscribe( + (securitySettings) => { + this.securitySettings = securitySettings; + this.securitySettingsFormGroup.reset(this.securitySettings); + } + ); + } + + confirmForm(): FormGroup { + return this.securitySettingsFormGroup; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss b/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss new file mode 100644 index 0000000000..840f0b031e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/settings-card.scss @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2019 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 "../../../../../scss/constants"; + +:host { + mat-card.settings-card { + margin: 8px; + @media #{$mat-gt-sm} { + width: 60%; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index cbd37fcc65..08cf06cf2d 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -16,21 +16,23 @@ import { NgModule } from '@angular/core'; -// import { AdminModule } from './admin/admin.module'; +import { AdminModule } from './admin/admin.module'; import { HomeLinksModule } from './home-links/home-links.module'; -// import { ProfileModule } from './profile/profile.module'; +import { ProfileModule } from './profile/profile.module'; +import { TenantModule } from '@modules/home/pages/tenant/tenant.module'; // import { CustomerModule } from '@modules/home/pages/customer/customer.module'; // import { AuditLogModule } from '@modules/home/pages/audit-log/audit-log.module'; -// import { UserModule } from '@modules/home/pages/user/user.module'; +import { UserModule } from '@modules/home/pages/user/user.module'; @NgModule({ exports: [ -// AdminModule, + AdminModule, HomeLinksModule, -// ProfileModule, + ProfileModule, + TenantModule, // CustomerModule, // AuditLogModule, -// UserModule + UserModule ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html new file mode 100644 index 0000000000..3fd4bce498 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.html @@ -0,0 +1,63 @@ + +
+ +

profile.change-password

+ + +
+ + +
+
+ + profile.current-password + + lock + + + login.new-password + + lock + + + login.new-password-again + + lock + +
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss new file mode 100644 index 0000000000..bb18c2c7b6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.scss @@ -0,0 +1,17 @@ +/** + * Copyright © 2016-2019 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 { +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts new file mode 100644 index 0000000000..451ce03cae --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/change-password-dialog.component.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthService } from '@core/auth/auth.service'; + +@Component({ + selector: 'tb-change-password-dialog', + templateUrl: './change-password-dialog.component.html', + styleUrls: ['./change-password-dialog.component.scss'] +}) +export class ChangePasswordDialogComponent extends PageComponent implements OnInit { + + changePassword: FormGroup; + + constructor(protected store: Store, + private translate: TranslateService, + private authService: AuthService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.buildChangePasswordForm(); + } + + buildChangePasswordForm() { + this.changePassword = this.fb.group({ + currentPassword: [''], + newPassword: [''], + newPassword2: [''] + }); + } + + onChangePassword(): void { + if (this.changePassword.get('newPassword').value !== this.changePassword.get('newPassword2').value) { + this.store.dispatch(new ActionNotificationShow({ message: this.translate.instant('login.passwords-mismatch-error'), + type: 'error' })); + } else { + this.authService.changePassword( + this.changePassword.get('currentPassword').value, + this.changePassword.get('newPassword').value).subscribe(() => { + this.dialogRef.close(true); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts new file mode 100644 index 0000000000..4989a280e4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -0,0 +1,69 @@ +/// +/// Copyright © 2016-2019 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 {Injectable, NgModule} from '@angular/core'; +import {Resolve, RouterModule, Routes} from '@angular/router'; + +import {ProfileComponent} from './profile.component'; +import {ConfirmOnExitGuard} from '@core/guards/confirm-on-exit.guard'; +import {Authority} from '@shared/models/authority.enum'; +import {User} from '@shared/models/user.model'; +import {Store} from '@ngrx/store'; +import {AppState} from '@core/core.state'; +import {UserService} from '@core/http/user.service'; +import {getCurrentAuthUser} from '@core/auth/auth.selectors'; +import {Observable} from 'rxjs'; + +@Injectable() +export class UserProfileResolver implements Resolve { + + constructor(private store: Store, + private userService: UserService) { + } + + resolve(): Observable { + const userId = getCurrentAuthUser(this.store).userId; + return this.userService.getUser(userId); + } +} + +const routes: Routes = [ + { + path: 'profile', + component: ProfileComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'profile.profile', + breadcrumb: { + label: 'profile.profile', + icon: 'account_circle' + } + }, + resolve: { + user: UserProfileResolver + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + UserProfileResolver + ] +}) +export class ProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html new file mode 100644 index 0000000000..ef59570148 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -0,0 +1,77 @@ + +
+ + +
+ profile.profile + {{ profile ? profile.get('email').value : '' }} +
+
+ + +
+ +
+
+ + user.email + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + + + user.first-name + + + + user.last-name + + + + language.language + + + {{ lang ? ('language.locales.' + lang | translate) : ''}} + + + +
+ +
+
+ + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss new file mode 100644 index 0000000000..4060d42f3b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2019 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 "../../../../../scss/constants"; + +:host { + mat-card.profile-card { + margin: 8px; + @media #{$mat-gt-sm} { + width: 60%; + } + .mat-headline { + margin: 0; + } + .profile-email { + font-size: 16px; + font-weight: 400; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts new file mode 100644 index 0000000000..1f2dbfd697 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts @@ -0,0 +1,123 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { UserService } from '@core/http/user.service'; +import { User } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; +import { PageComponent } from '@shared/components/page.component'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthUser, selectAuthUser } from '@core/auth/auth.selectors'; +import { mergeMap, take } from 'rxjs/operators'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { ActionAuthUpdateUserDetails } from '@core/auth/auth.actions'; +import { environment as env } from '@env/environment'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionSettingsChangeLanguage } from '@core/settings/settings.actions'; +import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component'; +import { MatDialog } from '@angular/material'; +import { DialogService } from '@core/services/dialog.service'; +import { AuthService } from '@core/auth/auth.service'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'tb-profile', + templateUrl: './profile.component.html', + styleUrls: ['./profile.component.scss'] +}) +export class ProfileComponent extends PageComponent implements OnInit, HasConfirmForm { + + authorities = Authority; + profile: FormGroup; + user: User; + languageList = env.supportedLangs; + + constructor(protected store: Store, + private route: ActivatedRoute, + private userService: UserService, + private authService: AuthService, + private translate: TranslateService, + public dialog: MatDialog, + public dialogService: DialogService, + public fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.buildProfileForm(); + this.userLoaded(this.route.snapshot.data.user); + } + + buildProfileForm() { + this.profile = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + firstName: [''], + lastName: [''], + language: [''] + }); + } + + save(): void { + this.user = {...this.user, ...this.profile.value}; + if (!this.user.additionalInfo) { + this.user.additionalInfo = {}; + } + this.user.additionalInfo.lang = this.profile.get('language').value; + this.userService.saveUser(this.user).subscribe( + (user) => { + this.userLoaded(user); + this.store.dispatch(new ActionAuthUpdateUserDetails({ userDetails: { + additionalInfo: {...user.additionalInfo}, + authority: user.authority, + createdTime: user.createdTime, + tenantId: user.tenantId, + customerId: user.customerId, + email: user.email, + firstName: user.firstName, + id: user.id, + lastName: user.lastName, + } })); + this.store.dispatch(new ActionSettingsChangeLanguage({ userLang: user.additionalInfo.lang })); + } + ); + } + + changePassword(): void { + this.dialog.open(ChangePasswordDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }); + } + + userLoaded(user: User) { + this.user = user; + this.profile.reset(user); + let lang; + if (user.additionalInfo && user.additionalInfo.lang) { + lang = user.additionalInfo.lang; + } else { + lang = this.translate.currentLang; + } + this.profile.get('language').setValue(lang); + } + + confirmForm(): FormGroup { + return this.profile; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts new file mode 100644 index 0000000000..fad3304d5e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ProfileComponent } from './profile.component'; +import { SharedModule } from '@shared/shared.module'; +import { ProfileRoutingModule } from './profile-routing.module'; +import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component'; + +@NgModule({ + entryComponents: [ + ChangePasswordDialogComponent + ], + declarations: [ + ProfileComponent, + ChangePasswordDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + ProfileRoutingModule + ] +}) +export class ProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts new file mode 100644 index 0000000000..e38bbbf92f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-routing.module.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2019 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 { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; + +import { EntitiesTableComponent } from '@shared/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { TenantsTableConfigResolver } from '@modules/home/pages/tenant/tenants-table-config.resolver'; +import { ProfileComponent } from '@modules/home/pages/profile/profile.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Customer } from '@shared/models/customer.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { forkJoin, Observable, throwError } from 'rxjs'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { catchError, finalize, map, tap } from 'rxjs/operators'; +import {UsersTableConfigResolver} from '../user/users-table-config.resolver'; + +const routes: Routes = [ + { + path: 'tenants', + data: { + breadcrumb: { + label: 'tenant.tenants', + icon: 'supervisor_account' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN], + title: 'tenant.tenants' + }, + resolve: { + entitiesTableConfig: TenantsTableConfigResolver + } + }, + { + path: ':tenantId/users', + component: EntitiesTableComponent, + data: { + auth: [Authority.SYS_ADMIN], + title: 'user.tenant-admins', + breadcrumb: { + label: 'user.tenant-admins', + icon: 'account_circle' + } + }, + resolve: { + entitiesTableConfig: UsersTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + TenantsTableConfigResolver + ] +}) +export class TenantRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html new file mode 100644 index 0000000000..e4f3f54a21 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html @@ -0,0 +1,61 @@ + +
+ + +
+ +
+
+
+
+
+ + tenant.title + + + {{ 'tenant.title-required' | translate }} + + +
+ + tenant.description + + +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts new file mode 100644 index 0000000000..a01408bd08 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts @@ -0,0 +1,76 @@ +/// +/// Copyright © 2016-2019 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 } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Customer } from '@shared/models/customer.model'; +import { ContactBasedComponent } from '@shared/components/entity/contact-based.component'; +import {Tenant} from '@app/shared/models/tenant.model'; +import {ActionNotificationShow} from '@app/core/notification/notification.actions'; +import {TranslateService} from '@ngx-translate/core'; + +@Component({ + selector: 'tb-tenant', + templateUrl: './tenant.component.html' +}) +export class TenantComponent extends ContactBasedComponent { + + constructor(protected store: Store, + protected translate: TranslateService, + protected fb: FormBuilder) { + super(store, fb); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildEntityForm(entity: Tenant): FormGroup { + return this.fb.group( + { + title: [entity ? entity.title : '', [Validators.required]], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''] + } + ) + } + ); + } + + updateEntityForm(entity: Tenant) { + this.entityForm.patchValue({title: entity.title}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + } + + onTenantIdCopied(event) { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('tenant.idCopiedMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts new file mode 100644 index 0000000000..83dfaa35bd --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.module.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import {TenantComponent} from '@modules/home/pages/tenant/tenant.component'; +import {TenantRoutingModule} from '@modules/home/pages/tenant/tenant-routing.module'; + +@NgModule({ + entryComponents: [ + TenantComponent + ], + declarations: [ + TenantComponent + ], + imports: [ + CommonModule, + SharedModule, + TenantRoutingModule + ] +}) +export class TenantModule { } diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts new file mode 100644 index 0000000000..0ad5fe2318 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenants-table-config.resolver.ts @@ -0,0 +1,105 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; + +import { Resolve, Router } from '@angular/router'; + +import { Tenant } from '@shared/models/tenant.model'; +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@shared/components/entity/entities-table-config.models'; +import { TenantService } from '@core/http/tenant.service'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { + EntityType, + entityTypeResources, + entityTypeTranslations +} from '@shared/models/entity-type.models'; +import { TenantComponent } from '@modules/home/pages/tenant/tenant.component'; +import { EntityAction } from '@shared/components/entity/entity-component.models'; +import { User } from '@shared/models/user.model'; + +@Injectable() +export class TenantsTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private tenantService: TenantService, + private translate: TranslateService, + private datePipe: DatePipe, + private router: Router) { + + this.config.entityType = EntityType.CUSTOMER; + this.config.entityComponent = TenantComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.TENANT); + this.config.entityResources = entityTypeResources.get(EntityType.TENANT); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'tenant.created-time', this.datePipe, '150px'), + new EntityTableColumn('title', 'tenant.title'), + new EntityTableColumn('email', 'contact.email'), + new EntityTableColumn('country', 'contact.country'), + new EntityTableColumn('city', 'contact.city') + ); + + this.config.cellActionDescriptors.push( + { + name: this.translate.instant('tenant.manage-tenant-admins'), + icon: 'account_circle', + isEnabled: () => true, + onAction: ($event, entity) => this.manageTenantAdmins($event, entity) + } + ); + + this.config.deleteEntityTitle = tenant => this.translate.instant('tenant.delete-tenant-title', { tenantTitle: tenant.title }); + this.config.deleteEntityContent = () => this.translate.instant('tenant.delete-tenant-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('tenant.delete-tenants-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('tenant.delete-tenants-text'); + + this.config.entitiesFetchFunction = pageLink => this.tenantService.getTenants(pageLink); + this.config.loadEntity = id => this.tenantService.getTenant(id.id); + this.config.saveEntity = tenant => this.tenantService.saveTenant(tenant); + this.config.deleteEntity = id => this.tenantService.deleteTenant(id.id); + this.config.onEntityAction = action => this.onTenantAction(action); + } + + resolve(): EntityTableConfig { + this.config.tableTitle = this.translate.instant('tenant.tenants'); + + return this.config; + } + + manageTenantAdmins($event: Event, tenant: Tenant) { + if ($event) { + $event.stopPropagation(); + } + this.router.navigateByUrl(`tenants/${tenant.id.id}/users`); + } + + onTenantAction(action: EntityAction): boolean { + switch (action.action) { + case 'manageTenantAdmins': + this.manageTenantAdmins(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html new file mode 100644 index 0000000000..86439161d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html @@ -0,0 +1,59 @@ + +
+ +

user.activation-link

+ + +
+ + +
+
+
+ +
+
{{ activationLink }}
+ +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts new file mode 100644 index 0000000000..92490c17d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts @@ -0,0 +1,64 @@ +/// +/// Copyright © 2016-2019 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; + +export interface ActivationLinkDialogData { + activationLink: string; +} + +@Component({ + selector: 'tb-activation-link-dialog', + templateUrl: './activation-link-dialog.component.html' +}) +export class ActivationLinkDialogComponent extends PageComponent implements OnInit { + + activationLink: string; + + constructor(protected store: Store, + @Inject(MAT_DIALOG_DATA) public data: ActivationLinkDialogData, + public dialogRef: MatDialogRef, + private translate: TranslateService) { + super(store); + this.activationLink = this.data.activationLink; + } + + ngOnInit(): void { + } + + close(): void { + this.dialogRef.close(); + } + + onActivationLinkCopied() { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.activation-link-copied-message'), + type: 'success', + target: 'activationLinkDialogContent', + duration: 1200, + verticalPosition: 'bottom', + horizontalPosition: 'left' + })); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html new file mode 100644 index 0000000000..092aa03842 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html @@ -0,0 +1,59 @@ + +
+ +

user.add

+ +
+ +
+ + +
+
+ + + user.activation-method + + + {{ activationMethodTranslations.get(activationMethods[activationMethod]) | translate }} + + + +
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts new file mode 100644 index 0000000000..ae8667dd37 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2019 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, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { NgForm } from '@angular/forms'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { Authority } from '@shared/models/authority.enum'; +import { ActivationMethod, activationMethodTranslations, User } from '@shared/models/user.model'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { UserService } from '@core/http/user.service'; +import { Observable } from 'rxjs'; +import { + ActivationLinkDialogComponent, + ActivationLinkDialogData +} from '@modules/home/pages/user/activation-link-dialog.component'; +import {TenantId} from '@app/shared/models/id/tenant-id'; + +export interface AddUserDialogData { + tenantId: string; + customerId: string; + authority: Authority; +} + +@Component({ + selector: 'tb-add-user-dialog', + templateUrl: './add-user-dialog.component.html' +}) +export class AddUserDialogComponent extends PageComponent implements OnInit { + + detailsForm: NgForm; + user: User; + + activationMethods = ActivationMethod; + + activationMethodTranslations = activationMethodTranslations; + + activationMethod = ActivationMethod.DISPLAY_ACTIVATION_LINK; + + @ViewChild(UserComponent, {static: true}) userComponent: UserComponent; + + constructor(protected store: Store, + @Inject(MAT_DIALOG_DATA) public data: AddUserDialogData, + public dialogRef: MatDialogRef, + private userService: UserService, + private dialog: MatDialog) { + super(store); + } + + ngOnInit(): void { + this.user = {} as User; + this.userComponent.isEdit = true; + this.userComponent.entity = this.user; + this.detailsForm = this.userComponent.entityNgForm; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.detailsForm.valid) { + this.user = {...this.user, ...this.userComponent.entityForm.value}; + this.user.authority = this.data.authority; + this.user.tenantId = new TenantId(this.data.tenantId); + this.user.customerId = new CustomerId(this.data.customerId); + const sendActivationEmail = this.activationMethod === ActivationMethod.SEND_ACTIVATION_MAIL; + this.userService.saveUser(this.user, sendActivationEmail).subscribe( + (user) => { + if (this.activationMethod === ActivationMethod.DISPLAY_ACTIVATION_LINK) { + this.userService.getActivationLink(user.id.id).subscribe( + (activationLink) => { + this.displayActivationLink(activationLink).subscribe( + () => { + this.dialogRef.close(user); + } + ); + } + ); + } else { + this.dialogRef.close(user); + } + } + ); + } + } + + displayActivationLink(activationLink: string): Observable { + return this.dialog.open(ActivationLinkDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + activationLink + } + }).afterClosed(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts b/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts new file mode 100644 index 0000000000..691800cc36 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user-routing.module.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { UsersTableConfigResolver } from '@modules/home/pages/user/users-table-config.resolver'; + +@NgModule({ + imports: [], + exports: [RouterModule], + providers: [ + UsersTableConfigResolver + ] +}) +export class UserRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.html b/ui-ngx/src/app/modules/home/pages/user/user.component.html new file mode 100644 index 0000000000..b514783dbf --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.html @@ -0,0 +1,90 @@ + +
+ + + + +
+
+
+
+ + user.email + + + {{ 'user.invalid-email-format' | translate }} + + + {{ 'user.email-required' | translate }} + + + + user.first-name + + + + user.last-name + + +
+ + user.description + + +
+
+ + + {{ 'user.always-fullscreen' | translate }} + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.scss b/ui-ngx/src/app/modules/home/pages/user/user.component.scss new file mode 100644 index 0000000000..b8be9f8df8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2019 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 "../../../../../scss/constants"; + +:host { + .tb-default-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.ts b/ui-ngx/src/app/modules/home/pages/user/user.component.ts new file mode 100644 index 0000000000..b861af909a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.ts @@ -0,0 +1,82 @@ +/// +/// Copyright © 2016-2019 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, OnInit } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '@shared/components/entity/entity.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { User } from '@shared/models/user.model'; +import { selectAuth, selectUserDetails } from '@core/auth/auth.selectors'; +import { map } from 'rxjs/operators'; +import { Authority } from '@shared/models/authority.enum'; + +@Component({ + selector: 'tb-user', + templateUrl: './user.component.html', + styleUrls: ['./user.component.scss'] +}) +export class UserComponent extends EntityComponent { + + authority = Authority; + + loginAsUserEnabled$ = this.store.pipe( + select(selectAuth), + map((auth) => auth.userTokenAccessEnabled) + ); + + constructor(protected store: Store, + public fb: FormBuilder) { + super(store); + } + + hideDelete() { + if (this.entitiesTableConfig) { + return !this.entitiesTableConfig.deleteEnabled(this.entity); + } else { + return false; + } + } + + buildForm(entity: User): FormGroup { + return this.fb.group( + { + email: [entity ? entity.email : '', [Validators.required, Validators.email]], + firstName: [entity ? entity.firstName : ''], + lastName: [entity ? entity.lastName : ''], + additionalInfo: this.fb.group( + { + description: [entity && entity.additionalInfo ? entity.additionalInfo.description : ''], + defaultDashboardId: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null], + defaultDashboardFullscreen: [entity && entity.additionalInfo ? entity.additionalInfo.defaultDashboardFullscreen : false], + } + ) + } + ); + } + + updateForm(entity: User) { + this.entityForm.patchValue({email: entity.email}); + this.entityForm.patchValue({firstName: entity.firstName}); + this.entityForm.patchValue({lastName: entity.lastName}); + this.entityForm.patchValue({additionalInfo: {description: entity.additionalInfo ? entity.additionalInfo.description : ''}}); + this.entityForm.patchValue({additionalInfo: + {defaultDashboardId: entity.additionalInfo ? entity.additionalInfo.defaultDashboardId : null}}); + this.entityForm.patchValue({additionalInfo: + {defaultDashboardFullscreen: entity.additionalInfo ? entity.additionalInfo.defaultDashboardFullscreen : false}}); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user.module.ts b/ui-ngx/src/app/modules/home/pages/user/user.module.ts new file mode 100644 index 0000000000..a5a4143cec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/user.module.ts @@ -0,0 +1,42 @@ +/// +/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { UserRoutingModule } from '@modules/home/pages/user/user-routing.module'; +import { AddUserDialogComponent } from '@modules/home/pages/user/add-user-dialog.component'; +import { ActivationLinkDialogComponent } from '@modules/home/pages/user/activation-link-dialog.component'; + +@NgModule({ + entryComponents: [ + UserComponent, + AddUserDialogComponent, + ActivationLinkDialogComponent + ], + declarations: [ + UserComponent, + AddUserDialogComponent, + ActivationLinkDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + UserRoutingModule + ] +}) +export class UserModule { } diff --git a/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts new file mode 100644 index 0000000000..b1774cd184 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/user/users-table-config.resolver.ts @@ -0,0 +1,230 @@ +/// +/// Copyright © 2016-2019 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 { Injectable } from '@angular/core'; + +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@shared/components/entity/entities-table-config.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { + EntityType, + entityTypeResources, + entityTypeTranslations +} from '@shared/models/entity-type.models'; +import { User } from '@shared/models/user.model'; +import { UserService } from '@core/http/user.service'; +import { UserComponent } from '@modules/home/pages/user/user.component'; +import { CustomerService } from '@core/http/customer.service'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { forkJoin, noop, Observable, of } from 'rxjs'; +import { Authority } from '@shared/models/authority.enum'; +import { CustomerId } from '@shared/models/id/customer-id'; +import { MatDialog } from '@angular/material'; +import { EntityAction } from '@shared/components/entity/entity-component.models'; +import { + AddUserDialogComponent, + AddUserDialogData +} from '@modules/home/pages/user/add-user-dialog.component'; +import { AuthState } from '@core/auth/auth.models'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { selectAuth } from '@core/auth/auth.selectors'; +import { AuthService } from '@core/auth/auth.service'; +import { + ActivationLinkDialogComponent, + ActivationLinkDialogData +} from '@modules/home/pages/user/activation-link-dialog.component'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { Customer } from '@shared/models/customer.model'; +import {TenantService} from '@app/core/http/tenant.service'; +import {TenantId} from '@app/shared/models/id/tenant-id'; + +export interface UsersTableRouteData { + authority: Authority; +} + +@Injectable() +export class UsersTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + private tenantId: string; + private customerId: string; + private authority: Authority; + private authUser: User; + + constructor(private store: Store, + private userService: UserService, + private authService: AuthService, + private tenantService: TenantService, + private customerService: CustomerService, + private translate: TranslateService, + private datePipe: DatePipe, + private dialog: MatDialog) { + + this.config.entityType = EntityType.USER; + this.config.entityComponent = UserComponent; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.USER); + this.config.entityResources = entityTypeResources.get(EntityType.USER); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'user.created-time', this.datePipe, '150px'), + new EntityTableColumn('firstName', 'user.first-name'), + new EntityTableColumn('lastName', 'user.last-name'), + new EntityTableColumn('email', 'user.email') + ); + + this.config.deleteEnabled = user => user && user.id && user.id.id !== this.authUser.id.id; + this.config.deleteEntityTitle = user => this.translate.instant('user.delete-user-title', { userEmail: user.email }); + this.config.deleteEntityContent = () => this.translate.instant('user.delete-user-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('user.delete-users-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('user.delete-users-text'); + + this.config.loadEntity = id => this.userService.getUser(id.id); + this.config.saveEntity = user => this.saveUser(user); + this.config.deleteEntity = id => this.userService.deleteUser(id.id); + this.config.onEntityAction = action => this.onUserAction(action); + this.config.addEntity = () => this.addUser(); + } + + resolve(route: ActivatedRouteSnapshot): Observable> { + const routeParams = route.params; + return this.store.pipe(select(selectAuth), take(1)).pipe( + tap((auth) => { + this.authUser = auth.userDetails; + this.authority = routeParams.tenantId ? Authority.TENANT_ADMIN : Authority.CUSTOMER_USER; + if (this.authority === Authority.TENANT_ADMIN) { + this.tenantId = routeParams.tenantId; + this.customerId = NULL_UUID; + this.config.entitiesFetchFunction = pageLink => this.userService.getTenantAdmins(this.tenantId, pageLink); + } else { + this.tenantId = this.authUser.tenantId.id; + this.customerId = routeParams.customerId; + this.config.entitiesFetchFunction = pageLink => this.userService.getCustomerUsers(this.customerId, pageLink); + } + this.updateActionCellDescriptors(auth); + }), + mergeMap(() => this.authority === Authority.TENANT_ADMIN ? + this.tenantService.getTenant(this.tenantId) : + this.customerService.getCustomer(this.customerId)), + map((parentEntity) => { + if (this.authority === Authority.TENANT_ADMIN) { + this.config.tableTitle = parentEntity.title + ': ' + this.translate.instant('user.tenant-admins'); + } else { + this.config.tableTitle = parentEntity.title + ': ' + this.translate.instant('user.customer-users'); + } + return this.config; + }) + ); + } + + updateActionCellDescriptors(auth: AuthState) { + this.config.cellActionDescriptors.splice(0); + if (auth.userTokenAccessEnabled) { + this.config.cellActionDescriptors.push( + { + name: this.authority === Authority.TENANT_ADMIN ? + this.translate.instant('user.login-as-tenant-admin') : + this.translate.instant('user.login-as-customer-user'), + icon: 'mdi:login', + isMdiIcon: true, + isEnabled: () => true, + onAction: ($event, entity) => this.loginAsUser($event, entity) + } + ); + } + } + + saveUser(user: User): Observable { + user.tenantId = new TenantId(this.tenantId); + user.customerId = new CustomerId(this.customerId); + user.authority = this.authority; + return this.userService.saveUser(user); + } + + addUser(): Observable { + return this.dialog.open(AddUserDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + tenantId: this.tenantId, + customerId: this.customerId, + authority: this.authority + } + }).afterClosed(); + } + + loginAsUser($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.authService.loginAsUser(user.id.id).subscribe(); + } + + displayActivationLink($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.userService.getActivationLink(user.id.id).subscribe( + (activationLink) => { + this.dialog.open(ActivationLinkDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + activationLink + } + }); + } + ); + } + + resendActivation($event: Event, user: User) { + if ($event) { + $event.stopPropagation(); + } + this.userService.sendActivationEmail(user.email).subscribe(() => { + this.store.dispatch(new ActionNotificationShow( + { + message: this.translate.instant('user.activation-email-sent-message'), + type: 'success' + })); + }); + } + + onUserAction(action: EntityAction): boolean { + switch (action.action) { + case 'loginAsUser': + this.loginAsUser(action.event, action.entity); + return true; + case 'displayActivationLink': + this.displayActivationLink(action.event, action.entity); + return true; + case 'resendActivation': + this.resendActivation(action.event, action.entity); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html new file mode 100644 index 0000000000..f4854853ba --- /dev/null +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -0,0 +1,63 @@ + +
+ + contact.country + + + {{ country }} + + + +
+ + contact.city + + + + contact.state + + + + contact.postal-code + + + {{ 'contact.postal-code-invalid' | translate }} + + +
+ + contact.address + + + + contact.address2 + + + + contact.phone + + + + contact.email + + + {{ 'user.invalid-email-format' | translate }} + + +
diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts new file mode 100644 index 0000000000..f16993bfbc --- /dev/null +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2019 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, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { COUNTRIES } from '@shared/components/contact.models'; + +@Component({ + selector: 'tb-contact', + templateUrl: './contact.component.html' +}) +export class ContactComponent { + + @Input() + parentForm: FormGroup; + + @Input() isEdit: boolean; + + countries = COUNTRIES; + +} diff --git a/ui-ngx/src/app/shared/components/contact.models.ts b/ui-ngx/src/app/shared/components/contact.models.ts new file mode 100644 index 0000000000..e8fc277049 --- /dev/null +++ b/ui-ngx/src/app/shared/components/contact.models.ts @@ -0,0 +1,291 @@ +/// +/// Copyright © 2016-2019 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 const COUNTRIES = [ + 'Afghanistan', + 'Åland Islands', + 'Albania', + 'Algeria', + 'American Samoa', + 'Andorra', + 'Angola', + 'Anguilla', + 'Antarctica', + 'Antigua and Barbuda', + 'Argentina', + 'Armenia', + 'Aruba', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bermuda', + 'Bhutan', + 'Bolivia', + 'Bonaire, Sint Eustatius and Saba', + 'Bosnia and Herzegovina', + 'Botswana', + 'Bouvet Island', + 'Brazil', + 'British Indian Ocean Territory', + 'Brunei Darussalam', + 'Bulgaria', + 'Burkina Faso', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Canada', + 'Cape Verde', + 'Cayman Islands', + 'Central African Republic', + 'Chad', + 'Chile', + 'China', + 'Christmas Island', + 'Cocos (Keeling) Islands', + 'Colombia', + 'Comoros', + 'Congo', + 'Congo, The Democratic Republic of the', + 'Cook Islands', + 'Costa Rica', + 'Côte d\'Ivoire', + 'Croatia', + 'Cuba', + 'Curaçao', + 'Cyprus', + 'Czech Republic', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Eritrea', + 'Estonia', + 'Ethiopia', + 'Falkland Islands (Malvinas)', + 'Faroe Islands', + 'Fiji', + 'Finland', + 'France', + 'French Guiana', + 'French Polynesia', + 'French Southern Territories', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Gibraltar', + 'Greece', + 'Greenland', + 'Grenada', + 'Guadeloupe', + 'Guam', + 'Guatemala', + 'Guernsey', + 'Guinea', + 'Guinea-Bissau', + 'Guyana', + 'Haiti', + 'Heard Island and McDonald Islands', + 'Holy See (Vatican City State)', + 'Honduras', + 'Hong Kong', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran, Islamic Republic of', + 'Iraq', + 'Ireland', + 'Isle of Man', + 'Israel', + 'Italy', + 'Jamaica', + 'Japan', + 'Jersey', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kiribati', + 'Korea, Democratic People\'s Republic of', + 'Korea, Republic of', + 'Kuwait', + 'Kyrgyzstan', + 'Lao People\'s Democratic Republic', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macao', + 'Macedonia, Republic Of', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Marshall Islands', + 'Martinique', + 'Mauritania', + 'Mauritius', + 'Mayotte', + 'Mexico', + 'Micronesia, Federated States of', + 'Moldova, Republic of', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Montserrat', + 'Morocco', + 'Mozambique', + 'Myanmar', + 'Namibia', + 'Nauru', + 'Nepal', + 'Netherlands', + 'New Caledonia', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Niue', + 'Norfolk Island', + 'Northern Mariana Islands', + 'Norway', + 'Oman', + 'Pakistan', + 'Palau', + 'Palestinian Territory, Occupied', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Pitcairn', + 'Poland', + 'Portugal', + 'Puerto Rico', + 'Qatar', + 'Reunion', + 'Romania', + 'Russian Federation', + 'Rwanda', + 'Saint Barthélemy', + 'Saint Helena, Ascension and Tristan da Cunha', + 'Saint Kitts and Nevis', + 'Saint Lucia', + 'Saint Martin (French Part)', + 'Saint Pierre and Miquelon', + 'Saint Vincent and the Grenadines', + 'Samoa', + 'San Marino', + 'Sao Tome and Principe', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Sint Maarten (Dutch Part)', + 'Slovakia', + 'Slovenia', + 'Solomon Islands', + 'Somalia', + 'South Africa', + 'South Georgia and the South Sandwich Islands', + 'South Sudan', + 'Spain', + 'Sri Lanka', + 'Sudan', + 'Suriname', + 'Svalbard and Jan Mayen', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syrian Arab Republic', + 'Taiwan', + 'Tajikistan', + 'Tanzania, United Republic of', + 'Thailand', + 'Timor-Leste', + 'Togo', + 'Tokelau', + 'Tonga', + 'Trinidad and Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Turks and Caicos Islands', + 'Tuvalu', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + 'United States', + 'United States Minor Outlying Islands', + 'Uruguay', + 'Uzbekistan', + 'Vanuatu', + 'Venezuela', + 'Viet Nam', + 'Virgin Islands, British', + 'Virgin Islands, U.S.', + 'Wallis and Futuna', + 'Western Sahara', + 'Yemen', + 'Zambia', + 'Zimbabwe' +]; + +/* tslint:disable */ +export const POSTAL_CODE_PATTERNS = { + 'United States': '(\\d{5}([\\-]\\d{4})?)', + 'Australia': '[0-9]{4}', + 'Austria': '[0-9]{4}', + 'Belgium': '[0-9]{4}', + 'Brazil': '[0-9]{5}[\\-]?[0-9]{3}', + 'Canada': '^(?!.*[DFIOQU])[A-VXY][0-9][A-Z][ -]?[0-9][A-Z][0-9]$', + 'Denmark': '[0-9]{3,4}', + 'Faroe Islands': '[0-9]{3,4}', + 'Netherlands': '[1-9][0-9]{3}\\s?[a-zA-Z]{2}', + 'Germany': '[0-9]{5}', + 'Hungary': '[0-9]{4}', + 'Italy': '[0-9]{5}', + 'Japan': '\\d{3}-\\d{4}', + 'Luxembourg': '(L\\s*(-|—|–))\\s*?[\\d]{4}', + 'Poland': '[0-9]{2}\\-[0-9]{3}', + 'Spain': '((0[1-9]|5[0-2])|[1-4][0-9])[0-9]{3}', + 'Sweden': '\\d{3}\\s?\\d{2}', + 'United Kingdom': '[A-Za-z]{1,2}[0-9Rr][0-9A-Za-z]? [0-9][ABD-HJLNP-UW-Zabd-hjlnp-uw-z]{2}' +}; +/* tslint:enable */ + diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html new file mode 100644 index 0000000000..ebffafd58f --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html @@ -0,0 +1,46 @@ + + + + + + + + + + + {{ translate.get('dashboard.no-dashboards-matching', {entity: searchText}) | async }} + + + + + + + + + + diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts new file mode 100644 index 0000000000..ad2ba9b4a6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -0,0 +1,226 @@ +/// +/// Copyright © 2016-2019 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 {AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild} from '@angular/core'; +import {ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {Observable, of} from 'rxjs'; +import {PageLink} from '@shared/models/page/page-link'; +import {Direction} from '@shared/models/page/sort-order'; +import {map, mergeMap, startWith, tap} from 'rxjs/operators'; +import {PageData, emptyPageData} from '@shared/models/page/page-data'; +import {DashboardInfo} from '@app/shared/models/dashboard.models'; +import {DashboardId} from '@app/shared/models/id/dashboard-id'; +import {DashboardService} from '@core/http/dashboard.service'; +import {Store} from '@ngrx/store'; +import {AppState} from '@app/core/core.state'; +import {getCurrentAuthUser} from '@app/core/auth/auth.selectors'; +import {Authority} from '@shared/models/authority.enum'; +import {TranslateService} from '@ngx-translate/core'; + +@Component({ + selector: 'tb-dashboard-autocomplete', + templateUrl: './dashboard-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DashboardAutocompleteComponent), + multi: true + }] +}) +export class DashboardAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + selectDashboardFormGroup: FormGroup; + + modelValue: DashboardInfo | string | null; + + @Input() + useIdValue = true; + + @Input() + selectFirstDashboard = false; + + @Input() + placeholder: string; + + @Input() + dashboardsScope: 'customer' | 'tenant'; + + @Input() + tenantId: string; + + @Input() + customerId: string; + + @Input() + required: boolean; + + @Input() + disabled: boolean; + + @ViewChild('dashboardInput', {static: true}) dashboardInput: ElementRef; + + filteredDashboards: Observable>; + + private valueLoaded = false; + + private searchText = ''; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private dashboardService: DashboardService, + private fb: FormBuilder) { + this.selectDashboardFormGroup = this.fb.group({ + dashboard: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + } + + ngAfterViewInit(): void { + this.selectFirstDashboardIfNeeded(); + } + + selectFirstDashboardIfNeeded(): void { + if (this.selectFirstDashboard && !this.modelValue) { + this.getDashboards(new PageLink(1)).subscribe( + (data) => { + if (data.data.length) { + const dashboard = data.data[0]; + this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; + this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: false}); + this.propagateChange(this.modelValue); + } + } + ); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + initFilteredResults(): void { + this.filteredDashboards = this.selectDashboardFormGroup.get('dashboard').valueChanges + .pipe( + startWith(''), + tap(value => { + if (this.valueLoaded) { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = this.useIdValue ? value.id.id : value; + } + this.updateView(modelValue); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchDashboards(name) ) + ); + } + + writeValue(value: DashboardInfo | string | null): void { + this.valueLoaded = false; + this.searchText = ''; + this.initFilteredResults(); + if (value != null) { + if (typeof value === 'string') { + this.dashboardService.getDashboardInfo(value).subscribe( + (dashboard) => { + this.modelValue = this.useIdValue ? dashboard.id.id : dashboard; + this.selectDashboardFormGroup.get('dashboard').patchValue(dashboard, {emitEvent: true}); + this.valueLoaded = true; + } + ); + } else { + this.modelValue = this.useIdValue ? value.id.id : value; + this.selectDashboardFormGroup.get('dashboard').patchValue(value, {emitEvent: false}); + this.valueLoaded = true; + } + } else { + this.modelValue = null; + this.selectDashboardFormGroup.get('dashboard').patchValue(null, {emitEvent: false}); + this.valueLoaded = true; + } + } + + updateView(value: DashboardInfo | string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayDashboardFn(dashboard?: DashboardInfo): string | undefined { + return dashboard ? dashboard.title : undefined; + } + + fetchDashboards(searchText?: string): Observable> { + this.searchText = searchText; + const pageLink = new PageLink(10, 0, searchText, { + property: 'title', + direction: Direction.ASC + }); + return this.getDashboards(pageLink).pipe( + map(pageData => { + return pageData.data; + }) + ); + } + + getDashboards(pageLink: PageLink): Observable> { + let dashboardsObservable: Observable>; + const authUser = getCurrentAuthUser(this.store); + if (this.dashboardsScope === 'customer' || authUser.authority === Authority.CUSTOMER_USER) { + if (this.customerId) { + dashboardsObservable = this.dashboardService.getCustomerDashboards(this.customerId, pageLink, false, true); + } else { + dashboardsObservable = of(emptyPageData()); + } + } else { + if (authUser.authority === Authority.SYS_ADMIN) { + if (this.tenantId) { + dashboardsObservable = this.dashboardService.getTenantDashboardsByTenantId(this.tenantId, pageLink, false, true); + } else { + dashboardsObservable = of(emptyPageData()); + } + } else { + dashboardsObservable = this.dashboardService.getTenantDashboards(pageLink, false, true); + } + } + return dashboardsObservable; + } + + clear() { + this.selectDashboardFormGroup.get('dashboard').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.dashboardInput.nativeElement.blur(); + this.dashboardInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/details-panel.component.html b/ui-ngx/src/app/shared/components/details-panel.component.html new file mode 100644 index 0000000000..05f855fc43 --- /dev/null +++ b/ui-ngx/src/app/shared/components/details-panel.component.html @@ -0,0 +1,56 @@ + +
+ +
+
+ {{ headerTitle }} + {{ headerSubtitle }} + + + +
+ + +
+
+ + +
+
+
+
+ +
diff --git a/ui-ngx/src/app/shared/components/details-panel.component.scss b/ui-ngx/src/app/shared/components/details-panel.component.scss new file mode 100644 index 0000000000..bcbc36cbbd --- /dev/null +++ b/ui-ngx/src/app/shared/components/details-panel.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2019 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 '../../../scss/constants'; + +:host { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + .mat-toolbar-tools { + height: 100%; + min-height: 100px; + max-height: 120px; + } + .tb-details-title { + width: inherit; + margin: 20px 8px 0 0; + overflow: hidden; + font-size: 1rem; + font-weight: 400; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + + @media #{$mat-gt-sm} { + font-size: 1.6rem; + } + } + + .tb-details-subtitle { + width: inherit; + margin: 10px 0; + overflow: hidden; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; + opacity: .8; + } + +} diff --git a/ui-ngx/src/app/shared/components/details-panel.component.ts b/ui-ngx/src/app/shared/components/details-panel.component.ts new file mode 100644 index 0000000000..43a9f43990 --- /dev/null +++ b/ui-ngx/src/app/shared/components/details-panel.component.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2019 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, EventEmitter, Input, Output } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { NgForm } from '@angular/forms'; + +@Component({ + selector: 'tb-details-panel', + templateUrl: './details-panel.component.html', + styleUrls: ['./details-panel.component.scss'] +}) +export class DetailsPanelComponent extends PageComponent { + + @Input() headerHeightPx = 100; + @Input() headerTitle = ''; + @Input() headerSubtitle = ''; + @Input() isReadOnly = false; + @Input() isAlwaysEdit = false; + @Input() theForm: NgForm; + @Output() + closeDetails = new EventEmitter(); + @Output() + toggleDetailsEditMode = new EventEmitter(); + @Output() + applyDetails = new EventEmitter(); + + isEditValue = false; + + @Output() + isEditChange = new EventEmitter(); + + @Input() + get isEdit() { + return this.isEditValue; + } + + set isEdit(val: boolean) { + this.isEditValue = val; + this.isEditChange.emit(this.isEditValue); + } + + + constructor(protected store: Store) { + super(store); + } + + onCloseDetails() { + this.closeDetails.emit(); + } + + onToggleDetailsEditMode() { + if (!this.isAlwaysEdit) { + this.isEdit = !this.isEdit; + } + this.toggleDetailsEditMode.emit(this.isEditValue); + } + + onApplyDetails() { + if (this.theForm.valid) { + this.applyDetails.emit(); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.html b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.html new file mode 100644 index 0000000000..f3f9c3ec9a --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.html @@ -0,0 +1,51 @@ + +
+ +

{{ translations.add }}

+ +
+ +
+ + +
+
+ +
+
+ + + +
+
diff --git a/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.scss b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.scss new file mode 100644 index 0000000000..bb18c2c7b6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.scss @@ -0,0 +1,17 @@ +/** + * Copyright © 2016-2019 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 { +} diff --git a/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.ts b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.ts new file mode 100644 index 0000000000..a4c0de7ec3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/add-entity-dialog.component.ts @@ -0,0 +1,103 @@ +/// +/// Copyright © 2016-2019 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, + ComponentFactoryResolver, + Inject, + OnInit, + SkipSelf, + ViewChild +} from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormControl, FormGroupDirective, NgForm } from '@angular/forms'; +import { EntityTypeResource, EntityTypeTranslation } from '@shared/models/entity-type.models'; +import { EntityTableConfig } from '@shared/components/entity/entities-table-config.models'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AddEntityDialogData } from '@shared/components/entity/entity-component.models'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { EntityComponent } from '@shared/components/entity/entity.component'; + +@Component({ + selector: 'tb-add-entity-dialog', + templateUrl: './add-entity-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AddEntityDialogComponent}], + styleUrls: ['./add-entity-dialog.component.scss'] +}) +export class AddEntityDialogComponent extends PageComponent implements OnInit, ErrorStateMatcher { + + entityComponent: EntityComponent>; + detailsForm: NgForm; + + entitiesTableConfig: EntityTableConfig>; + translations: EntityTypeTranslation; + resources: EntityTypeResource; + entity: BaseData; + + submitted = false; + + @ViewChild('entityDetailsForm', {static: true}) entityDetailsFormAnchor: TbAnchorComponent; + + constructor(protected store: Store, + @Inject(MAT_DIALOG_DATA) public data: AddEntityDialogData>, + public dialogRef: MatDialogRef>, + private componentFactoryResolver: ComponentFactoryResolver, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher) { + super(store); + } + + ngOnInit(): void { + this.entitiesTableConfig = this.data.entitiesTableConfig; + this.translations = this.entitiesTableConfig.entityTranslations; + this.resources = this.entitiesTableConfig.entityResources; + this.entity = {}; + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.entitiesTableConfig.entityComponent); + const viewContainerRef = this.entityDetailsFormAnchor.viewContainerRef; + viewContainerRef.clear(); + const componentRef = viewContainerRef.createComponent(componentFactory); + this.entityComponent = componentRef.instance; + this.entityComponent.isEdit = true; + this.entityComponent.entitiesTableConfig = this.entitiesTableConfig; + this.entityComponent.entity = this.entity; + this.detailsForm = this.entityComponent.entityNgForm; + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + this.submitted = true; + if (this.detailsForm.valid) { + this.entity = {...this.entity, ...this.entityComponent.entityFormValue()}; + this.entitiesTableConfig.saveEntity(this.entity).subscribe( + (entity) => { + this.dialogRef.close(entity); + } + ); + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/contact-based.component.ts b/ui-ngx/src/app/shared/components/entity/contact-based.component.ts new file mode 100644 index 0000000000..9f4a50a73f --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/contact-based.component.ts @@ -0,0 +1,84 @@ +/// +/// Copyright © 2016-2019 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityComponent } from '@shared/components/entity/entity.component'; +import { FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { ContactBased } from '@shared/models/contact-based.model'; +import { AfterViewInit } from '@angular/core'; +import { POSTAL_CODE_PATTERNS } from '@shared/components/contact.models'; +import { HasId } from '@shared/models/base-data'; + +export abstract class ContactBasedComponent> extends EntityComponent implements AfterViewInit { + + constructor(protected store: Store, + protected fb: FormBuilder) { + super(store); + } + + buildForm(entity: T): FormGroup { + const entityForm = this.buildEntityForm(entity); + entityForm.addControl('country', this.fb.control(entity ? entity.country : '', [])); + entityForm.addControl('city', this.fb.control(entity ? entity.city : '', [])); + entityForm.addControl('state', this.fb.control(entity ? entity.state : '', [])); + entityForm.addControl('zip', this.fb.control(entity ? entity.zip : '', + this.zipValidators(entity ? entity.country : '') + )); + entityForm.addControl('address', this.fb.control(entity ? entity.address : '', [])); + entityForm.addControl('address2', this.fb.control(entity ? entity.address2 : '', [])); + entityForm.addControl('phone', this.fb.control(entity ? entity.phone : '', [])); + entityForm.addControl('email', this.fb.control(entity ? entity.email : '', [Validators.email])); + return entityForm; + } + + updateForm(entity: T) { + this.updateEntityForm(entity); + this.entityForm.patchValue({country: entity.country}); + this.entityForm.patchValue({city: entity.city}); + this.entityForm.patchValue({state: entity.state}); + this.entityForm.get('zip').setValidators(this.zipValidators(entity.country)); + this.entityForm.patchValue({zip: entity.zip}); + this.entityForm.patchValue({address: entity.address}); + this.entityForm.patchValue({address2: entity.address2}); + this.entityForm.patchValue({phone: entity.phone}); + this.entityForm.patchValue({email: entity.email}); + } + + ngAfterViewInit() { + this.entityForm.get('country').valueChanges.subscribe( + (country) => { + this.entityForm.get('zip').setValidators(this.zipValidators(country)); + this.entityForm.get('zip').updateValueAndValidity({onlySelf: true}); + this.entityForm.get('zip').markAsTouched({onlySelf: true}); + } + ); + } + + zipValidators(country: string): ValidatorFn[] { + const zipValidators = []; + if (country && POSTAL_CODE_PATTERNS[country]) { + const postalCodePattern = POSTAL_CODE_PATTERNS[country]; + zipValidators.push(Validators.pattern(postalCodePattern)); + } + return zipValidators; + } + + abstract buildEntityForm(entity: T): FormGroup; + + abstract updateEntityForm(entity: T); + +} diff --git a/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts b/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts new file mode 100644 index 0000000000..5a6c9d26a6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts @@ -0,0 +1,137 @@ +/// +/// Copyright © 2016-2019 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 { BaseData, HasId } from '@shared/models/base-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesFetchFunction } from '@shared/models/datasource/entity-datasource'; +import { Observable, of } from 'rxjs'; +import { emptyPageData } from '@shared/models/page/page-data'; +import { DatePipe } from '@angular/common'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { + EntityType, + EntityTypeResource, + EntityTypeTranslation +} from '@shared/models/entity-type.models'; +import { EntityComponent } from '@shared/components/entity/entity.component'; +import { Type } from '@angular/core'; +import { EntityAction } from '@shared/components/entity/entity-component.models'; +import { HasUUID } from '@shared/models/id/has-uuid'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntitiesTableComponent } from '@shared/components/entity/entities-table.component'; +import { EntityTableHeaderComponent } from '@shared/components/entity/entity-table-header.component'; +import { ActivatedRoute } from '@angular/router'; + +export type EntityBooleanFunction> = (entity: T) => boolean; +export type EntityStringFunction> = (entity: T) => string; +export type EntityCountStringFunction = (count: number) => string; +export type EntityTwoWayOperation> = (entity: T) => Observable; +export type EntityByIdOperation> = (id: HasUUID) => Observable; +export type EntityIdOneWayOperation = (id: HasUUID) => Observable; +export type EntityActionFunction> = (action: EntityAction) => boolean; +export type CreateEntityOperation> = () => Observable; + +export type CellContentFunction> = (entity: T, key: string) => string; +export type CellStyleFunction> = (entity: T, key: string) => object; + +export interface CellActionDescriptor> { + name: string; + nameFunction?: (entity: T) => string; + icon?: string; + isMdiIcon?: boolean; + color?: string; + isEnabled: (entity: T) => boolean; + onAction: ($event: MouseEvent, entity: T) => void; +} + +export interface GroupActionDescriptor> { + name: string; + icon: string; + isEnabled: boolean; + onAction: ($event: MouseEvent, entities: T[]) => void; +} + +export interface HeaderActionDescriptor { + name: string; + icon: string; + isEnabled: () => boolean; + onAction: ($event: MouseEvent) => void; +} + +export class EntityTableColumn> { + constructor(public key: string, + public title: string, + public maxWidth: string = '100%', + public cellContentFunction: CellContentFunction = (entity, property) => entity[property], + public cellStyleFunction: CellStyleFunction = () => ({})) { + } +} + +export class DateEntityTableColumn> extends EntityTableColumn { + constructor(key: string, + title: string, + datePipe: DatePipe, + maxWidth: string = '100%', + dateFormat: string = 'yyyy-MM-dd HH:mm:ss', + cellStyleFunction: CellStyleFunction = () => ({})) { + super(key, + title, + maxWidth, + (entity, property) => datePipe.transform(entity[property], dateFormat), + cellStyleFunction); + } +} + +export class EntityTableConfig, P extends PageLink = PageLink> { + + constructor() {} + + componentsData: any = null; + + loadDataOnInit = true; + onLoadAction: (route: ActivatedRoute) => void = null; + table: EntitiesTableComponent = null; + useTimePageLink = false; + entityType: EntityType = null; + tableTitle = ''; + selectionEnabled = true; + searchEnabled = true; + addEnabled = true; + entitiesDeleteEnabled = true; + detailsPanelEnabled = true; + actionsColumnTitle = null; + entityTranslations: EntityTypeTranslation; + entityResources: EntityTypeResource; + entityComponent: Type>; + defaultSortOrder: SortOrder = {property: 'createdTime', direction: Direction.ASC}; + columns: Array> = []; + cellActionDescriptors: Array> = []; + groupActionDescriptors: Array> = []; + headerActionDescriptors: Array = []; + headerComponent: Type>; + addEntity: CreateEntityOperation = null; + detailsReadonly: EntityBooleanFunction = () => false; + deleteEnabled: EntityBooleanFunction = () => true; + deleteEntityTitle: EntityStringFunction = () => ''; + deleteEntityContent: EntityStringFunction = () => ''; + deleteEntitiesTitle: EntityCountStringFunction = () => ''; + deleteEntitiesContent: EntityCountStringFunction = () => ''; + loadEntity: EntityByIdOperation = () => of(); + saveEntity: EntityTwoWayOperation = (entity) => of(entity); + deleteEntity: EntityIdOneWayOperation = () => of(); + entitiesFetchFunction: EntitiesFetchFunction = () => of(emptyPageData()); + onEntityAction: EntityActionFunction = () => false; +} diff --git a/ui-ngx/src/app/shared/components/entity/entities-table.component.html b/ui-ngx/src/app/shared/components/entity/entities-table.component.html new file mode 100644 index 0000000000..d5d0d95d51 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entities-table.component.html @@ -0,0 +1,182 @@ + + + + + + + +
+
+ +
+ {{ entitiesTableConfig.tableTitle }} + + + + + + + +
+
+ +
+ + +   + + + +
+
+ +
+ + {{ translate.get(translations.selectedEntities, {count: dataSource.selection.selected.length}) | async }} + + + +
+
+
+ + + + + + + + + + + + + {{ column.title | translate }} + + + + + {{ entitiesTableConfig.actionsColumnTitle ? (entitiesTableConfig.actionsColumnTitle | translate) : '' }} + + +
+ +
+
+ + + + +
+
+
+ + +
+ {{ translations.noEntities }} +
+ + +
+
+
+
diff --git a/ui-ngx/src/app/shared/components/entity/entities-table.component.scss b/ui-ngx/src/app/shared/components/entity/entities-table.component.scss new file mode 100644 index 0000000000..b4dd472432 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entities-table.component.scss @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2019 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 { + width: 100%; + height: 100%; + .tb-entity-table { + .tb-entity-table-content { + width: 100%; + height: 100%; + background: #fff; + + .tb-entity-table-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table-container { + overflow: auto; + } + } + } +} + +:host ::ng-deep .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; +} diff --git a/ui-ngx/src/app/shared/components/entity/entities-table.component.ts b/ui-ngx/src/app/shared/components/entity/entities-table.component.ts new file mode 100644 index 0000000000..0fa6248581 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entities-table.component.ts @@ -0,0 +1,358 @@ +/// +/// Copyright © 2016-2019 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 { + AfterViewInit, + Component, ComponentFactoryResolver, + ElementRef, + Input, + OnInit, + Type, + ViewChild +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageLink, TimePageLink } from '@shared/models/page/page-link'; +import { MatDialog, MatPaginator, MatSort } from '@angular/material'; +import { EntitiesDataSource } from '@shared/models/datasource/entity-datasource'; +import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { forkJoin, fromEvent, merge, Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { ActivatedRoute } from '@angular/router'; +import { + CellActionDescriptor, + EntityTableColumn, + EntityTableConfig, + GroupActionDescriptor, + HeaderActionDescriptor +} from '@shared/components/entity/entities-table-config.models'; +import { EntityTypeTranslation } from '@shared/models/entity-type.models'; +import { DialogService } from '@core/services/dialog.service'; +import { AddEntityDialogComponent } from '@shared/components/entity/add-entity-dialog.component'; +import { + AddEntityDialogData, + EntityAction +} from '@shared/components/entity/entity-component.models'; +import { Timewindow } from '@shared/models/time/time.models'; +import { DomSanitizer } from '@angular/platform-browser'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; + +@Component({ + selector: 'tb-entities-table', + templateUrl: './entities-table.component.html', + styleUrls: ['./entities-table.component.scss'] +}) +export class EntitiesTableComponent extends PageComponent implements AfterViewInit, OnInit { + + @Input() + entitiesTableConfig: EntityTableConfig>; + + translations: EntityTypeTranslation; + + headerActionDescriptors: Array; + groupActionDescriptors: Array>>; + cellActionDescriptors: Array>>; + + columns: Array>>; + displayedColumns: string[] = []; + + selectionEnabled; + + pageLink: PageLink; + textSearchMode = false; + timewindow: Timewindow; + dataSource: EntitiesDataSource>; + + isDetailsOpen = false; + + @ViewChild('entityTableHeader', {static: false}) entityTableHeaderAnchor: TbAnchorComponent; + + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef; + + @ViewChild(MatPaginator, {static: false}) paginator: MatPaginator; + @ViewChild(MatSort, {static: false}) sort: MatSort; + + constructor(protected store: Store, + private route: ActivatedRoute, + public translate: TranslateService, + public dialog: MatDialog, + private dialogService: DialogService, + private domSanitizer: DomSanitizer, + private componentFactoryResolver: ComponentFactoryResolver) { + super(store); + } + + ngOnInit() { + this.entitiesTableConfig = this.entitiesTableConfig || this.route.snapshot.data.entitiesTableConfig; + if (this.entitiesTableConfig.headerComponent) { + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.entitiesTableConfig.headerComponent); + const viewContainerRef = this.entityTableHeaderAnchor.viewContainerRef; + viewContainerRef.clear(); + const componentRef = viewContainerRef.createComponent(componentFactory); + const headerComponent = componentRef.instance; + headerComponent.entitiesTableConfig = this.entitiesTableConfig; + } + + this.entitiesTableConfig.table = this; + this.translations = this.entitiesTableConfig.entityTranslations; + + this.headerActionDescriptors = [...this.entitiesTableConfig.headerActionDescriptors]; + this.groupActionDescriptors = [...this.entitiesTableConfig.groupActionDescriptors]; + this.cellActionDescriptors = [...this.entitiesTableConfig.cellActionDescriptors]; + + if (this.entitiesTableConfig.entitiesDeleteEnabled) { + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.delete'), + icon: 'delete', + isEnabled: entity => this.entitiesTableConfig.deleteEnabled(entity), + onAction: ($event, entity) => this.deleteEntity($event, entity) + } + ); + } + + this.groupActionDescriptors.push( + { + name: this.translate.instant('action.delete'), + icon: 'delete', + isEnabled: this.entitiesTableConfig.entitiesDeleteEnabled, + onAction: ($event, entities) => this.deleteEntities($event, entities) + } + ); + + this.columns = [...this.entitiesTableConfig.columns]; + + this.selectionEnabled = this.entitiesTableConfig.selectionEnabled; + + if (this.selectionEnabled) { + this.displayedColumns.push('select'); + } + this.columns.forEach( + (column) => { + this.displayedColumns.push(column.key); + } + ); + this.displayedColumns.push('actions'); + + const sortOrder: SortOrder = { property: this.entitiesTableConfig.defaultSortOrder.property, + direction: this.entitiesTableConfig.defaultSortOrder.direction }; + + if (this.entitiesTableConfig.useTimePageLink) { + this.timewindow = Timewindow.historyInterval(24 * 60 * 60 * 1000); + const currentTime = new Date().getTime(); + this.pageLink = new TimePageLink(10, 0, null, sortOrder, + currentTime - this.timewindow.history.timewindowMs, currentTime); + } else { + this.pageLink = new PageLink(10, 0, null, sortOrder); + } + this.dataSource = new EntitiesDataSource>( + this.entitiesTableConfig.entitiesFetchFunction + ); + if (this.entitiesTableConfig.onLoadAction) { + this.entitiesTableConfig.onLoadAction(this.route); + } + if (this.entitiesTableConfig.loadDataOnInit) { + this.dataSource.loadEntities(this.pageLink); + } + } + + ngAfterViewInit() { + + fromEvent(this.searchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + this.paginator.pageIndex = 0; + this.updateData(); + }) + ) + .subscribe(); + + this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); + + merge(this.sort.sortChange, this.paginator.page) + .pipe( + tap(() => this.updateData()) + ) + .subscribe(); + } + + addEnabled() { + return this.entitiesTableConfig.addEnabled; + } + + updateData(closeDetails: boolean = true) { + if (closeDetails) { + this.isDetailsOpen = false; + } + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + if (this.entitiesTableConfig.useTimePageLink) { + const timePageLink = this.pageLink as TimePageLink; + if (this.timewindow.history.timewindowMs) { + const currentTime = new Date().getTime(); + timePageLink.startTime = currentTime - this.timewindow.history.timewindowMs; + timePageLink.endTime = currentTime; + } else { + timePageLink.startTime = this.timewindow.history.fixedTimewindow.startTimeMs; + timePageLink.endTime = this.timewindow.history.fixedTimewindow.endTimeMs; + } + } + this.dataSource.loadEntities(this.pageLink); + } + + onRowClick($event: Event, entity) { + if ($event) { + $event.stopPropagation(); + } + if (this.dataSource.toggleCurrentEntity(entity)) { + this.isDetailsOpen = true; + } else { + this.isDetailsOpen = !this.isDetailsOpen; + } + } + + addEntity($event: Event) { + let entity$: Observable>; + if (this.entitiesTableConfig.addEntity) { + entity$ = this.entitiesTableConfig.addEntity(); + } else { + entity$ = this.dialog.open>, + BaseData>(AddEntityDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entitiesTableConfig: this.entitiesTableConfig + } + }).afterClosed(); + } + entity$.subscribe( + (entity) => { + if (entity) { + this.updateData(); + } + } + ); + } + + onEntityUpdated(entity: BaseData) { + this.updateData(false); + } + + onEntityAction(action: EntityAction>) { + if (action.action === 'delete') { + this.deleteEntity(action.event, action.entity); + } + } + + deleteEntity($event: Event, entity: BaseData) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.entitiesTableConfig.deleteEntityTitle(entity), + this.entitiesTableConfig.deleteEntityContent(entity), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.entitiesTableConfig.deleteEntity(entity.id).subscribe( + () => { + this.updateData(); + } + ); + } + }); + } + + deleteEntities($event: Event, entities: BaseData[]) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.entitiesTableConfig.deleteEntitiesTitle(entities.length), + this.entitiesTableConfig.deleteEntitiesContent(entities.length), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + const tasks: Observable[] = []; + entities.forEach((entity) => { + if (this.entitiesTableConfig.deleteEnabled(entity)) { + tasks.push(this.entitiesTableConfig.deleteEntity(entity.id)); + } + }); + forkJoin(tasks).subscribe( + () => { + this.updateData(); + } + ); + } + }); + } + + onTimewindowChange() { + this.updateData(); + } + + enterFilterMode() { + this.textSearchMode = true; + this.pageLink.textSearch = ''; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.pageLink.textSearch = null; + this.paginator.pageIndex = 0; + this.updateData(); + } + + resetSortAndFilter(update: boolean = true) { + this.pageLink.textSearch = null; + if (this.entitiesTableConfig.useTimePageLink) { + this.timewindow = Timewindow.historyInterval(24 * 60 * 60 * 1000); + } + this.paginator.pageIndex = 0; + const sortable = this.sort.sortables.get(this.entitiesTableConfig.defaultSortOrder.property); + this.sort.active = sortable.id; + this.sort.direction = this.entitiesTableConfig.defaultSortOrder.direction === Direction.ASC ? 'asc' : 'desc'; + if (update) { + this.updateData(); + } + } + + cellContent(entity: BaseData, column: EntityTableColumn>) { + return this.domSanitizer.bypassSecurityTrustHtml(column.cellContentFunction(entity, column.key)); + } + + cellStyle(entity: BaseData, column: EntityTableColumn>) { + return {...column.cellStyleFunction(entity, column.key), ...{maxWidth: column.maxWidth}}; + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-component.models.ts b/ui-ngx/src/app/shared/components/entity/entity-component.models.ts new file mode 100644 index 0000000000..a65d44a96c --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-component.models.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2019 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 { BaseData, HasId } from '@shared/models/base-data'; +import { EntityTableConfig } from '@shared/components/entity/entities-table-config.models'; + +export interface AddEntityDialogData> { + entitiesTableConfig: EntityTableConfig; +} + +export interface EntityAction> { + event: Event; + action: string; + entity: T; +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.html b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.html new file mode 100644 index 0000000000..23e4633fcf --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.html @@ -0,0 +1,43 @@ + + +
+
+
+ + + + + + +
diff --git a/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.scss b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.scss new file mode 100644 index 0000000000..d30159bdfa --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2019 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 { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +:host ::ng-deep { + .mat-tab-body-wrapper { + position: absolute; + top: 49px; + left: 0; + right: 0; + bottom: 0; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.ts new file mode 100644 index 0000000000..582b8b389f --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-details-panel.component.ts @@ -0,0 +1,165 @@ +/// +/// Copyright © 2016-2019 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, + ComponentFactoryResolver, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableConfig } from '@shared/components/entity/entities-table-config.models'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { + EntityType, + EntityTypeResource, + EntityTypeTranslation +} from '@shared/models/entity-type.models'; +import { NgForm } from '@angular/forms'; +import { EntityComponent } from '@shared/components/entity/entity.component'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { EntityAction } from '@shared/components/entity/entity-component.models'; +import { Subscription } from 'rxjs'; +// import { AuditLogMode } from '@shared/models/audit-log.models'; + +@Component({ + selector: 'tb-entity-details-panel', + templateUrl: './entity-details-panel.component.html', + styleUrls: ['./entity-details-panel.component.scss'] +}) +export class EntityDetailsPanelComponent extends PageComponent implements OnInit, OnDestroy { + + @Input() entitiesTableConfig: EntityTableConfig>; + + @Output() + closeEntityDetails = new EventEmitter(); + + @Output() + entityUpdated = new EventEmitter>(); + + @Output() + entityAction = new EventEmitter>>(); + + entityComponent: EntityComponent>; + detailsForm: NgForm; + + isEditValue = false; + selectedTab = 0; + + entityTypes = EntityType; + + @ViewChild('entityDetailsForm', {static: true}) entityDetailsFormAnchor: TbAnchorComponent; + + translations: EntityTypeTranslation; + resources: EntityTypeResource; + entity: BaseData; + + private currentEntityId: HasId; + private entityActionSubscription: Subscription; + + constructor(protected store: Store, + private componentFactoryResolver: ComponentFactoryResolver) { + super(store); + } + + @Input() + set entityId(entityId: HasId) { + if (entityId && entityId !== this.currentEntityId) { + this.currentEntityId = entityId; + this.reload(); + } + } + + set isEdit(val: boolean) { + this.isEditValue = val; + this.entityComponent.isEdit = val; + } + + get isEdit() { + return this.isEditValue; + } + + ngOnInit(): void { + this.translations = this.entitiesTableConfig.entityTranslations; + this.resources = this.entitiesTableConfig.entityResources; + this.buildEntityComponent(); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + if (this.entityActionSubscription) { + this.entityActionSubscription.unsubscribe(); + } + } + + buildEntityComponent() { + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.entitiesTableConfig.entityComponent); + const viewContainerRef = this.entityDetailsFormAnchor.viewContainerRef; + viewContainerRef.clear(); + const componentRef = viewContainerRef.createComponent(componentFactory); + this.entityComponent = componentRef.instance; + this.entityComponent.isEdit = this.isEdit; + this.entityComponent.entitiesTableConfig = this.entitiesTableConfig; + this.detailsForm = this.entityComponent.entityNgForm; + this.entityActionSubscription = this.entityComponent.entityAction.subscribe((action) => { + this.entityAction.emit(action); + }); + } + + reload(): void { + this.isEdit = false; + this.entitiesTableConfig.loadEntity(this.currentEntityId).subscribe( + (entity) => { + this.entity = entity; + this.entityComponent.entity = entity; + } + ); + } + + onCloseEntityDetails() { + this.closeEntityDetails.emit(); + } + + onToggleEditMode(isEdit: boolean) { + this.isEdit = isEdit; + if (!this.isEdit) { + this.entityComponent.entity = this.entity; + } else { + this.selectedTab = 0; + } + } + + saveEntity() { + if (this.detailsForm.valid) { + const editingEntity = {...this.entity, ...this.entityComponent.entityFormValue()}; + this.entitiesTableConfig.saveEntity(editingEntity).subscribe( + (entity) => { + this.entity = entity; + this.entityComponent.entity = entity; + this.isEdit = false; + this.entityUpdated.emit(this.entity); + } + ); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-table-header.component.ts b/ui-ngx/src/app/shared/components/entity/entity-table-header.component.ts new file mode 100644 index 0000000000..a398567e7f --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-table-header.component.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2019 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 { BaseData, HasId } from '@shared/models/base-data'; +import { PageComponent } from '@shared/components/page.component'; +import { Input, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTableConfig } from '@shared/components/entity/entities-table-config.models'; + +export abstract class EntityTableHeaderComponent> extends PageComponent implements OnInit { + + @Input() + entitiesTableConfig: EntityTableConfig; + + protected constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity.component.ts b/ui-ngx/src/app/shared/components/entity/entity.component.ts new file mode 100644 index 0000000000..63624a1dcd --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity.component.ts @@ -0,0 +1,110 @@ +/// +/// Copyright © 2016-2019 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 { BaseData, HasId } from '@shared/models/base-data'; +import { FormGroup, NgForm } from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityAction } from '@shared/components/entity/entity-component.models'; +import { EntityTableConfig } from '@shared/components/entity/entities-table-config.models'; + +export abstract class EntityComponent> extends PageComponent implements OnInit { + + entityValue: T; + entityForm: FormGroup; + + @ViewChild('entityNgForm', {static: true}) entityNgForm: NgForm; + + isEditValue: boolean; + + @Input() + set isEdit(isEdit: boolean) { + this.isEditValue = isEdit; + this.updateFormState(); + } + + get isEdit() { + return this.isEditValue; + } + + get isAdd(): boolean { + return this.entityValue && !this.entityValue.id; + } + + @Input() + set entity(entity: T) { + this.entityValue = entity; + if (this.entityForm) { + this.entityForm.reset(); + this.updateForm(entity); + } + } + + get entity(): T { + return this.entityValue; + } + + @Input() + entitiesTableConfig: EntityTableConfig; + + @Output() + entityAction = new EventEmitter>(); + + protected constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + this.entityForm = this.buildForm(this.entityValue); + } + + onEntityAction($event: Event, action: string) { + const entityAction = {event: $event, action, entity: this.entity} as EntityAction; + let handled = false; + if (this.entitiesTableConfig) { + handled = this.entitiesTableConfig.onEntityAction(entityAction); + } + if (!handled) { + this.entityAction.emit(entityAction); + } + } + + updateFormState() { + if (this.entityForm) { + if (this.isEditValue) { + this.entityForm.enable({emitEvent: false}); + } else { + this.entityForm.disable({emitEvent: false}); + } + } + } + + entityFormValue() { + const formValue = this.entityForm ? {...this.entityForm.value} : {}; + return this.prepareFormValue(formValue); + } + + prepareFormValue(formValue: any): any { + return formValue; + } + + abstract buildForm(entity: T): FormGroup; + + abstract updateForm(entity: T); + +} diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.html b/ui-ngx/src/app/shared/components/time/datetime-period.component.html new file mode 100644 index 0000000000..fa699ff5b4 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.html @@ -0,0 +1,47 @@ + +
+
+ + datetime.date-from + + + + + + datetime.time-from + + + + +
+
+ + datetime.date-to + + + + + + datetime.time-to + + + + +
+
diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.scss b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss new file mode 100644 index 0000000000..fbc1b776d6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2019 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 ::ng-deep { + .mat-form-field-wrapper { + padding-bottom: 8px; + } + .mat-form-field-underline { + bottom: 8px; + } + .mat-form-field-infix { + width: 150px; + } +} diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts new file mode 100644 index 0000000000..8afd0fb8e8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts @@ -0,0 +1,137 @@ +/// +/// Copyright © 2016-2019 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, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FixedWindow } from '@shared/models/time/time.models'; + +@Component({ + selector: 'tb-datetime-period', + templateUrl: './datetime-period.component.html', + styleUrls: ['./datetime-period.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatetimePeriodComponent), + multi: true + } + ] +}) +export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { + + @Input() disabled: boolean; + + modelValue: FixedWindow; + + startDate: Date; + endDate: Date; + + endTime: any; + + maxStartDate: Date; + minEndDate: Date; + maxEndDate: Date; + + changePending = false; + + private propagateChange = null; + + constructor() { + } + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (this.changePending && this.propagateChange) { + this.changePending = false; + this.propagateChange(this.modelValue); + } + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(datePeriod: FixedWindow): void { + this.modelValue = datePeriod; + if (this.modelValue) { + this.startDate = new Date(this.modelValue.startTimeMs); + this.endDate = new Date(this.modelValue.endTimeMs); + } else { + const date = new Date(); + this.startDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() - 1, + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds()); + this.endDate = date; + this.updateView(); + } + this.updateMinMaxDates(); + } + + updateView() { + let value: FixedWindow = null; + if (this.startDate && this.endDate) { + value = new FixedWindow(); + value.startTimeMs = this.startDate.getTime(); + value.endTimeMs = this.endDate.getTime(); + } + this.modelValue = value; + if (!this.propagateChange) { + this.changePending = true; + } else { + this.propagateChange(this.modelValue); + } + } + + updateMinMaxDates() { + this.maxStartDate = new Date(this.endDate.getTime() - 1000); + this.minEndDate = new Date(this.startDate.getTime() + 1000); + this.maxEndDate = new Date(); + } + + onStartDateChange() { + if (this.startDate) { + if (this.startDate.getTime() > this.maxStartDate.getTime()) { + this.startDate = new Date(this.maxStartDate.getTime()); + } + this.updateMinMaxDates(); + } + this.updateView(); + } + + onEndDateChange() { + if (this.endDate) { + if (this.endDate.getTime() < this.minEndDate.getTime()) { + this.endDate = new Date(this.minEndDate.getTime()); + } else if (this.endDate.getTime() > this.maxEndDate.getTime()) { + this.endDate = new Date(this.maxEndDate.getTime()); + } + this.updateMinMaxDates(); + } + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.html b/ui-ngx/src/app/shared/components/time/timeinterval.component.html new file mode 100644 index 0000000000..07ef698147 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.html @@ -0,0 +1,54 @@ + +
+
+ +
+ + timeinterval.days + + + + timeinterval.hours + + + + timeinterval.minutes + + + + timeinterval.seconds + + +
+
+
+ + {{ predefinedName }} + + + {{ interval.name | translate:interval.translateParams }} + + + +
+
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.scss b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss new file mode 100644 index 0000000000..00c76c042f --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.scss @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2019 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 { + min-width: 355px; + + .advanced-switch { + margin-bottom: 16px; + } + + .advanced-label { + margin: 5px 0; + } + + .interval-section { + min-height: 66px; + .interval-label { + margin-bottom: 7px; + margin-top: -1px; + } + } + +} + +:host ::ng-deep { + .number-input { + .mat-form-field-infix { + width: 70px; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts new file mode 100644 index 0000000000..16d72cea85 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -0,0 +1,277 @@ +/// +/// Copyright © 2016-2019 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 { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator +} from '@angular/forms'; +import { Timewindow } from '@shared/models/time/time.models'; +import { TimeInterval, TimeService } from '@core/services/time.service'; + +@Component({ + selector: 'tb-timeinterval', + templateUrl: './timeinterval.component.html', + styleUrls: ['./timeinterval.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeintervalComponent), + multi: true + } + ] +}) +export class TimeintervalComponent implements OnInit, ControlValueAccessor { + + minValue: number; + maxValue: number; + + @Input() + set min(min: number) { + if (typeof min !== 'undefined' && min !== this.minValue) { + this.minValue = min; + this.updateView(); + } + } + + @Input() + set max(max: number) { + if (typeof max !== 'undefined' && max !== this.maxValue) { + this.maxValue = max; + this.updateView(); + } + } + + @Input() predefinedName: string; + @Input() disabled: boolean; + + days = 0; + hours = 0; + mins = 1; + secs = 0; + + intervalMs = 0; + modelValue: number; + + advanced = false; + rendered = false; + + intervals: Array; + + private propagateChange = (_: any) => {}; + + constructor(private timeService: TimeService) { + } + + ngOnInit(): void { + this.boundInterval(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(intervalMs: number): void { + this.modelValue = intervalMs; + this.rendered = true; + if (typeof this.modelValue !== 'undefined') { + const min = this.timeService.boundMinInterval(this.minValue); + const max = this.timeService.boundMaxInterval(this.maxValue); + if (this.modelValue >= min && this.modelValue <= max) { + this.advanced = !this.timeService.matchesExistingInterval(this.minValue, this.maxValue, this.modelValue); + this.setIntervalMs(this.modelValue); + } else { + this.boundInterval(); + } + } + } + + setIntervalMs(intervalMs: number) { + if (!this.advanced) { + this.intervalMs = intervalMs; + } + const intervalSeconds = Math.floor(intervalMs / 1000); + this.days = Math.floor(intervalSeconds / 86400); + this.hours = Math.floor((intervalSeconds % 86400) / 3600); + this.mins = Math.floor(((intervalSeconds % 86400) % 3600) / 60); + this.secs = intervalSeconds % 60; + } + + boundInterval() { + const min = this.timeService.boundMinInterval(this.minValue); + const max = this.timeService.boundMaxInterval(this.maxValue); + this.intervals = this.timeService.getIntervals(this.minValue, this.maxValue); + if (this.rendered) { + let newIntervalMs = this.modelValue; + if (newIntervalMs < min) { + newIntervalMs = min; + } else if (newIntervalMs > max) { + newIntervalMs = max; + } + if (!this.advanced) { + newIntervalMs = this.timeService.boundToPredefinedInterval(min, max, newIntervalMs); + } + if (newIntervalMs !== this.modelValue) { + this.setIntervalMs(newIntervalMs); + this.updateView(); + } + } + } + + updateView() { + if (!this.rendered) { + return; + } + let value = null; + let intervalMs; + if (!this.advanced) { + intervalMs = this.intervalMs; + if (!intervalMs || isNaN(intervalMs)) { + intervalMs = this.calculateIntervalMs(); + } + } else { + intervalMs = this.calculateIntervalMs(); + } + if (!isNaN(intervalMs) && intervalMs > 0) { + value = intervalMs; + } + this.modelValue = value; + this.propagateChange(this.modelValue); + this.boundInterval(); + } + + calculateIntervalMs(): number { + return (this.days * 86400 + + this.hours * 3600 + + this.mins * 60 + + this.secs) * 1000; + } + + onIntervalMsChange() { + this.updateView(); + } + + onAdvancedChange() { + if (!this.advanced) { + this.intervalMs = this.calculateIntervalMs(); + } else { + let intervalMs = this.intervalMs; + if (!intervalMs || isNaN(intervalMs)) { + intervalMs = this.calculateIntervalMs(); + } + this.setIntervalMs(intervalMs); + } + this.updateView(); + } + + onTimeInputChange(type: string) { + switch (type) { + case 'secs': + setTimeout(() => this.onSecsChange(), 0); + break; + case 'mins': + setTimeout(() => this.onMinsChange(), 0); + break; + case 'hours': + setTimeout(() => this.onHoursChange(), 0); + break; + case 'days': + setTimeout(() => this.onDaysChange(), 0); + break; + } + } + + onSecsChange() { + if (typeof this.secs === 'undefined') { + return; + } + if (this.secs < 0) { + if ((this.days + this.hours + this.mins) > 0) { + this.secs = this.secs + 60; + this.mins--; + this.onMinsChange(); + } else { + this.secs = 0; + } + } else if (this.secs >= 60) { + this.secs = this.secs - 60; + this.mins++; + this.onMinsChange(); + } + this.updateView(); + } + + onMinsChange() { + if (typeof this.mins === 'undefined') { + return; + } + if (this.mins < 0) { + if ((this.days + this.hours) > 0) { + this.mins = this.mins + 60; + this.hours--; + this.onHoursChange(); + } else { + this.mins = 0; + } + } else if (this.mins >= 60) { + this.mins = this.mins - 60; + this.hours++; + this.onHoursChange(); + } + this.updateView(); + } + + onHoursChange() { + if (typeof this.hours === 'undefined') { + return; + } + if (this.hours < 0) { + if (this.days > 0) { + this.hours = this.hours + 24; + this.days--; + this.onDaysChange(); + } else { + this.hours = 0; + } + } else if (this.hours >= 24) { + this.hours = this.hours - 24; + this.days++; + this.onDaysChange(); + } + this.updateView(); + } + + onDaysChange() { + if (typeof this.days === 'undefined') { + return; + } + if (this.days < 0) { + this.days = 0; + } + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html new file mode 100644 index 0000000000..5f85596833 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -0,0 +1,127 @@ + +
+
+
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ timewindow.time-period + +
+
+
+
+
+
+
+ + aggregation.function + + + {{ aggregationTypesTranslations.get(aggregation) | translate }} + + + +
+ aggregation.limit + + + + + +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss new file mode 100644 index 0000000000..c2613a9482 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2019 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 { + width: 100%; + height: 100%; + form, + fieldset { + height: 100%; + } + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 0 16px; + } + + .limit-slider-container { + >:first-child { + margin-right: 16px; + } + >:last-child { + margin-left: 16px; + } + >:first-child, >:last-child { + min-width: 25px; + max-width: 42px; + } + mat-form-field input[type=number] { + text-align: center; + } + } + +} + +:host ::ng-deep { + mat-radio-button { + display: block; + margin-bottom: 16px; + .mat-radio-label { + width: 100%; + align-items: start; + .mat-radio-label-content { + width: 100%; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts new file mode 100644 index 0000000000..badceb71ea --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -0,0 +1,203 @@ +/// +/// Copyright © 2016-2019 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, + InjectionToken, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { + Aggregation, + aggregationTranslations, + AggregationType, + HistoryWindow, + HistoryWindowType, + IntervalWindow, + Timewindow, + TimewindowType +} from '@shared/models/time/time.models'; +import { DatePipe } from '@angular/common'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TimeService } from '@core/services/time.service'; + +export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelData'); + +export interface TimewindowPanelData { + historyOnly: boolean; + timewindow: Timewindow; + aggregation: boolean; +} + +@Component({ + selector: 'tb-timewindow-panel', + templateUrl: './timewindow-panel.component.html', + styleUrls: ['./timewindow-panel.component.scss'] +}) +export class TimewindowPanelComponent extends PageComponent implements OnInit { + + historyOnly = false; + + aggregation = false; + + timewindow: Timewindow; + + result: Timewindow; + + timewindowForm: FormGroup; + + historyTypes = HistoryWindowType; + + timewindowTypes = TimewindowType; + + aggregationTypes = AggregationType; + + aggregations = Object.keys(AggregationType); + + aggregationTypesTranslations = aggregationTranslations; + + constructor(@Inject(TIMEWINDOW_PANEL_DATA) public data: TimewindowPanelData, + public overlayRef: OverlayRef, + protected store: Store, + public fb: FormBuilder, + private timeService: TimeService, + private translate: TranslateService, + private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe, + private datePipe: DatePipe, + private overlay: Overlay, + public viewContainerRef: ViewContainerRef) { + super(store); + this.historyOnly = data.historyOnly; + this.timewindow = data.timewindow; + this.aggregation = data.aggregation; + } + + ngOnInit(): void { + this.timewindowForm = this.fb.group({ + realtime: this.fb.group( + { + timewindowMs: [ + this.timewindow.realtime && typeof this.timewindow.realtime.timewindowMs !== 'undefined' + ? this.timewindow.realtime.timewindowMs : null + ], + interval: [ + this.timewindow.realtime && typeof this.timewindow.realtime.interval !== 'undefined' + ? this.timewindow.realtime.interval : null + ] + } + ), + history: this.fb.group( + { + historyType: [ + this.timewindow.history && typeof this.timewindow.history.historyType !== 'undefined' + ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL + ], + timewindowMs: [ + this.timewindow.history && typeof this.timewindow.history.timewindowMs !== 'undefined' + ? this.timewindow.history.timewindowMs : null + ], + interval: [ + this.timewindow.history && typeof this.timewindow.history.interval !== 'undefined' + ? this.timewindow.history.interval : null + ], + fixedTimewindow: [ + this.timewindow.history && typeof this.timewindow.history.fixedTimewindow !== 'undefined' + ? this.timewindow.history.fixedTimewindow : null + ] + } + ), + aggregation: this.fb.group( + { + type: [ + this.timewindow.aggregation && typeof this.timewindow.aggregation.type !== 'undefined' + ? this.timewindow.aggregation.type : null + ], + limit: [ + this.timewindow.aggregation && typeof this.timewindow.aggregation.limit !== 'undefined' + ? this.timewindow.aggregation.limit : null, + [Validators.min(this.minDatapointsLimit()), Validators.max(this.maxDatapointsLimit())] + ] + } + ) + }); + } + + update() { + const timewindowFormValue = this.timewindowForm.value; + this.timewindow.realtime = new IntervalWindow(); + this.timewindow.realtime.timewindowMs = timewindowFormValue.realtime.timewindowMs; + this.timewindow.realtime.interval = timewindowFormValue.realtime.interval; + this.timewindow.history = new HistoryWindow(); + this.timewindow.history.historyType = timewindowFormValue.history.historyType; + this.timewindow.history.timewindowMs = timewindowFormValue.history.timewindowMs; + this.timewindow.history.interval = timewindowFormValue.history.interval; + this.timewindow.history.fixedTimewindow = timewindowFormValue.history.fixedTimewindow; + if (this.aggregation) { + this.timewindow.aggregation = new Aggregation(); + this.timewindow.aggregation.type = timewindowFormValue.aggregation.type; + this.timewindow.aggregation.limit = timewindowFormValue.aggregation.limit; + } + this.result = this.timewindow; + this.overlayRef.dispose(); + } + + cancel() { + this.overlayRef.dispose(); + } + + minDatapointsLimit() { + return this.timeService.getMinDatapointsLimit(); + } + + maxDatapointsLimit() { + return this.timeService.getMaxDatapointsLimit(); + } + + minRealtimeAggInterval() { + return this.timeService.minIntervalLimit(this.timewindowForm.get('realtime').get('timewindowMs').value); + } + + maxRealtimeAggInterval() { + return this.timeService.maxIntervalLimit(this.timewindowForm.get('realtime').get('timewindowMs').value); + } + + minHistoryAggInterval() { + return this.timeService.minIntervalLimit(this.currentHistoryTimewindow()); + } + + maxHistoryAggInterval() { + return this.timeService.maxIntervalLimit(this.currentHistoryTimewindow()); + } + + currentHistoryTimewindow() { + const timewindowFormValue = this.timewindowForm.value; + if (timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { + return timewindowFormValue.history.timewindowMs; + } else { + return timewindowFormValue.history.fixedTimewindow.endTimeMs - + timewindowFormValue.history.fixedTimewindow.startTimeMs; + } + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html new file mode 100644 index 0000000000..38b65e46a6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -0,0 +1,43 @@ + + +
+ + + {{innerValue.displayValue}} + + +
diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.scss b/ui-ngx/src/app/shared/components/time/timewindow.component.scss new file mode 100644 index 0000000000..0e267d0657 --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.scss @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2019 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 { + section.tb-timewindow { + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: all; + cursor: pointer; + } + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts new file mode 100644 index 0000000000..d28629fcdb --- /dev/null +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts @@ -0,0 +1,275 @@ +/// +/// Copyright © 2016-2019 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, + forwardRef, Inject, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { + HistoryWindowType, + Timewindow, + TimewindowType +} from '@shared/models/time/time.models'; +import { DatePipe } from '@angular/common'; +import { + Overlay, + CdkOverlayOrigin, + OverlayConfig, + OverlayPositionBuilder, ConnectedPosition, PositionStrategy, OverlayRef +} from '@angular/cdk/overlay'; +import { + TIMEWINDOW_PANEL_DATA, + TimewindowPanelComponent, + TimewindowPanelData +} from '@shared/components/time/timewindow-panel.component'; +import { ComponentPortal, PortalInjector } from '@angular/cdk/portal'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { DOCUMENT } from '@angular/common'; +import { WINDOW } from '@core/services/window.service'; +import { TimeService } from '@core/services/time.service'; +import { TooltipPosition } from '@angular/material/typings/tooltip'; + +@Component({ + selector: 'tb-timewindow', + templateUrl: './timewindow.component.html', + styleUrls: ['./timewindow.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimewindowComponent), + multi: true + } + ] +}) +export class TimewindowComponent implements OnInit, OnDestroy, ControlValueAccessor { + + historyOnlyValue = false; + + @Input() + set historyOnly(val) { + this.historyOnlyValue = true; + } + + get historyOnly() { + return this.historyOnlyValue; + } + + aggregationValue = false; + + @Input() + set aggregation(val) { + this.aggregationValue = true; + } + + get aggregation() { + return this.aggregationValue; + } + + isToolbarValue = false; + + @Input() + set isToolbar(val) { + this.isToolbarValue = true; + } + + get isToolbar() { + return this.isToolbarValue; + } + + asButtonValue = false; + + @Input() + set asButton(val) { + this.asButtonValue = true; + } + + get asButton() { + return this.asButtonValue; + } + + @Input() + direction: 'left' | 'right' = 'left'; + + @Input() + tooltipPosition: TooltipPosition = 'above'; + + @Input() disabled: boolean; + + @ViewChild('timewindowPanelOrigin', {static: false}) timewindowPanelOrigin: CdkOverlayOrigin; + + innerValue: Timewindow; + + private propagateChange = (_: any) => {}; + + constructor(private translate: TranslateService, + private timeService: TimeService, + private millisecondsToTimeStringPipe: MillisecondsToTimeStringPipe, + private datePipe: DatePipe, + private overlay: Overlay, + public viewContainerRef: ViewContainerRef, + public breakpointObserver: BreakpointObserver, + @Inject(DOCUMENT) private document: Document, + @Inject(WINDOW) private window: Window) { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + } + + openEditMode() { + if (this.disabled) { + return; + } + const isGtSm = this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']); + const position = this.overlay.position(); + const config = new OverlayConfig({ + panelClass: 'tb-timewindow-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: isGtSm, + }); + if (isGtSm) { + config.minWidth = '417px'; + config.maxHeight = '440px'; + const panelHeight = 375; + const panelWidth = 417; + const el = this.timewindowPanelOrigin.elementRef.nativeElement; + const offset = el.getBoundingClientRect(); + const scrollTop = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0; + const scrollLeft = this.window.pageXOffset || this.document.documentElement.scrollLeft || this.document.body.scrollLeft || 0; + const bottomY = offset.bottom - scrollTop; + const leftX = offset.left - scrollLeft; + let originX; + let originY; + let overlayX; + let overlayY; + const wHeight = this.document.documentElement.clientHeight; + const wWidth = this.document.documentElement.clientWidth; + if (bottomY + panelHeight > wHeight) { + originY = 'top'; + overlayY = 'bottom'; + } else { + originY = 'bottom'; + overlayY = 'top'; + } + if (leftX + panelWidth > wWidth) { + originX = 'end'; + overlayX = 'end'; + } else { + originX = 'start'; + overlayX = 'start'; + } + const connectedPosition: ConnectedPosition = { + originX, + originY, + overlayX, + overlayY + }; + config.positionStrategy = position.flexibleConnectedTo(this.timewindowPanelOrigin.elementRef) + .withPositions([connectedPosition]); + } else { + config.minWidth = '100%'; + config.minHeight = '100%'; + config.positionStrategy = position.global().top('0%').left('0%') + .right('0%').bottom('0%'); + } + + const overlayRef = this.overlay.create(config); + + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const injector = this._createTimewindowPanelInjector( + overlayRef, + { + timewindow: this.innerValue.clone(), + historyOnly: this.historyOnly, + aggregation: this.aggregation + } + ); + + const componentRef = overlayRef.attach(new ComponentPortal(TimewindowPanelComponent, this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result) { + this.innerValue = componentRef.instance.result; + this.updateDisplayValue(); + this.notifyChanged(); + } + }); + } + + private _createTimewindowPanelInjector(overlayRef: OverlayRef, data: TimewindowPanelData): PortalInjector { + const injectionTokens = new WeakMap([ + [TIMEWINDOW_PANEL_DATA, data], + [OverlayRef, overlayRef] + ]); + return new PortalInjector(this.viewContainerRef.injector, injectionTokens); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(obj: Timewindow): void { + this.innerValue = Timewindow.initModelFromDefaultTimewindow(obj, this.timeService); + this.updateDisplayValue(); + } + + notifyChanged() { + this.propagateChange(this.innerValue.cloneSelectedTimewindow()); + } + + updateDisplayValue() { + if (this.innerValue.selectedTab === TimewindowType.REALTIME && !this.historyOnly) { + this.innerValue.displayValue = this.translate.instant('timewindow.realtime') + ' - ' + + this.translate.instant('timewindow.last-prefix') + ' ' + + this.millisecondsToTimeStringPipe.transform(this.innerValue.realtime.timewindowMs); + } else { + this.innerValue.displayValue = !this.historyOnly ? (this.translate.instant('timewindow.history') + ' - ') : ''; + if (this.innerValue.history.historyType === HistoryWindowType.LAST_INTERVAL) { + this.innerValue.displayValue += this.translate.instant('timewindow.last-prefix') + ' ' + + this.millisecondsToTimeStringPipe.transform(this.innerValue.history.timewindowMs); + } else { + const startString = this.datePipe.transform(this.innerValue.history.fixedTimewindow.startTimeMs, 'yyyy-MM-dd HH:mm:ss'); + const endString = this.datePipe.transform(this.innerValue.history.fixedTimewindow.endTimeMs, 'yyyy-MM-dd HH:mm:ss'); + this.innerValue.displayValue += this.translate.instant('timewindow.period', {startTime: startString, endTime: endString}); + } + } + } + + hideLabel() { + return this.isToolbar && !this.breakpointObserver.isMatched(MediaBreakpoints['gt-md']); + } + +} diff --git a/ui-ngx/src/app/shared/models/customer.model.ts b/ui-ngx/src/app/shared/models/customer.model.ts index d7da52711d..adfa684fe0 100644 --- a/ui-ngx/src/app/shared/models/customer.model.ts +++ b/ui-ngx/src/app/shared/models/customer.model.ts @@ -23,3 +23,9 @@ export interface Customer extends ContactBased { title: string; additionalInfo?: any; } + +export interface ShortCustomerInfo { + customerId: CustomerId; + title: string; + isPublic: boolean; +} diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts new file mode 100644 index 0000000000..ce99407541 --- /dev/null +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2019 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 {BaseData} from '@shared/models/base-data'; +import {DashboardId} from '@shared/models/id/dashboard-id'; +import {TenantId} from '@shared/models/id/tenant-id'; +import {ShortCustomerInfo} from '@shared/models/customer.model'; + +export interface DashboardInfo extends BaseData { + tenantId: TenantId; + title: string; + assignedCustomers: Array; +} + +export interface DashboardConfiguration { + widgets: Array; + // TODO: +} + +export interface Dashboard extends DashboardInfo { + configuration: DashboardConfiguration; +} diff --git a/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts b/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts new file mode 100644 index 0000000000..f8bb0e2426 --- /dev/null +++ b/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts @@ -0,0 +1,113 @@ +/// +/// Copyright © 2016-2019 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 { EntityId } from '@shared/models/id/entity-id'; +import { PageLink } from '@shared/models/page/page-link'; +import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; +import { catchError, map, take, tap } from 'rxjs/operators'; +import { SelectionModel } from '@angular/cdk/collections'; + +export type EntitiesFetchFunction, P extends PageLink> = (pageLink: P) => Observable>; + +export class EntitiesDataSource, P extends PageLink = PageLink> implements DataSource { + + private entitiesSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + public selection = new SelectionModel(true, []); + + public currentEntity: T = null; + + constructor(private fetchFunction: EntitiesFetchFunction) {} + + connect(collectionViewer: CollectionViewer): Observable> { + return this.entitiesSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.entitiesSubject.complete(); + this.pageDataSubject.complete(); + } + + loadEntities(pageLink: P): Observable> { + const result = new ReplaySubject>(); + this.fetchFunction(pageLink).pipe( + tap(() => { + this.selection.clear(); + }), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.entitiesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + } + ); + return result; + } + + isAllSelected(): Observable { + const numSelected = this.selection.selected.length; + return this.entitiesSubject.pipe( + map((entities) => numSelected === entities.length) + ); + } + + isEmpty(): Observable { + return this.entitiesSubject.pipe( + map((entities) => !entities.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + + toggleCurrentEntity(entity: T): boolean { + if (this.currentEntity !== entity) { + this.currentEntity = entity; + return true; + } else { + return false; + } + } + + isCurrentEntity(entity: T): boolean { + return (this.currentEntity && entity && this.currentEntity.id && entity.id) && + (this.currentEntity.id.id === entity.id.id); + } + + masterToggle() { + this.entitiesSubject.pipe( + tap((entities) => { + const numSelected = this.selection.selected.length; + if (numSelected === entities.length) { + this.selection.clear(); + } else { + entities.forEach(row => this.selection.select(row)); + } + }), + take(1) + ).subscribe(); + } +} diff --git a/ui-ngx/src/app/shared/models/id/dashboard-id.ts b/ui-ngx/src/app/shared/models/id/dashboard-id.ts new file mode 100644 index 0000000000..d32877664e --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/dashboard-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2019 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 { EntityId } from './entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class DashboardId implements EntityId { + entityType = EntityType.DASHBOARD; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts index 6af535d257..147967b3e7 100644 --- a/ui-ngx/src/app/shared/models/settings.models.ts +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -33,3 +33,20 @@ export interface MailServerSettings { username: string; password: string; } + +export interface GeneralSettings { + baseUrl: string; +} + +export interface UserPasswordPolicy { + minimumLength: number; + minimumUppercaseLetters: number; + minimumLowercaseLetters: number; + minimumDigits: number; + minimumSpecialCharacters: number; + passwordExpirationPeriodDays: number; +} + +export interface SecuritySettings { + passwordPolicy: UserPasswordPolicy; +} diff --git a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts new file mode 100644 index 0000000000..02bed81a49 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts @@ -0,0 +1,27 @@ +/// +/// Copyright © 2016-2019 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'; + +@Pipe({ + name: 'enumToArray' +}) +export class EnumToArrayPipe implements PipeTransform { + transform(data: object) { + const keys = Object.keys(data); + return keys.slice(keys.length / 2); + } +} diff --git a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts new file mode 100644 index 0000000000..1fca8e7151 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts @@ -0,0 +1,28 @@ +/// +/// Copyright © 2016-2019 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'; + +@Pipe({ name: 'highlight' }) +export class HighlightPipe implements PipeTransform { + transform(text: string, search): string { + const pattern = search + .replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + const regex = new RegExp('^' + pattern, 'i'); + + return search ? text.replace(regex, match => `${match}`) : text; + } +} diff --git a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts new file mode 100644 index 0000000000..473dcd6d8e --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2019 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 { TranslateService } from '@ngx-translate/core'; + +@Pipe({ + name: 'milliSecondsToTimeString' +}) +export class MillisecondsToTimeStringPipe implements PipeTransform { + + constructor(private translate: TranslateService) { + } + + transform(millseconds: number, args?: any): string { + let seconds = Math.floor(millseconds / 1000); + const days = Math.floor(seconds / 86400); + let hours = Math.floor((seconds % 86400) / 3600); + let minutes = Math.floor(((seconds % 86400) % 3600) / 60); + seconds = seconds % 60; + let timeString = ''; + if (days > 0) { + timeString += this.translate.instant('timewindow.days', {days}); + } + if (hours > 0) { + if (timeString.length === 0 && hours === 1) { + hours = 0; + } + timeString += this.translate.instant('timewindow.hours', {hours}); + } + if (minutes > 0) { + if (timeString.length === 0 && minutes === 1) { + minutes = 0; + } + timeString += this.translate.instant('timewindow.minutes', {minutes}); + } + if (seconds > 0) { + if (timeString.length === 0 && seconds === 1) { + seconds = 0; + } + timeString += this.translate.instant('timewindow.seconds', {seconds}); + } + return timeString; + } +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 0106a0422c..ffd5591240 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -58,39 +58,41 @@ import { NospacePipe } from './pipe/nospace.pipe'; import { TranslateModule } from '@ngx-translate/core'; import { TbCheckboxComponent } from '@shared/components/tb-checkbox.component'; import { HelpComponent } from '@shared/components/help.component'; -// import { EntitiesTableComponent } from '@shared/components/entity/entities-table.component'; -// import { AddEntityDialogComponent } from '@shared/components/entity/add-entity-dialog.component'; -// import { DetailsPanelComponent } from '@shared/components/details-panel.component'; -// import { EntityDetailsPanelComponent } from '@shared/components/entity/entity-details-panel.component'; +import { EntitiesTableComponent } from '@shared/components/entity/entities-table.component'; +import { AddEntityDialogComponent } from '@shared/components/entity/add-entity-dialog.component'; +import { DetailsPanelComponent } from '@shared/components/details-panel.component'; +import { EntityDetailsPanelComponent } from '@shared/components/entity/entity-details-panel.component'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; -// import { ContactComponent } from '@shared/components/contact.component'; +import { ContactComponent } from '@shared/components/contact.component'; // import { AuditLogDetailsDialogComponent } from '@shared/components/audit-log/audit-log-details-dialog.component'; // import { AuditLogTableComponent } from '@shared/components/audit-log/audit-log-table.component'; -// import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; -// import { TimewindowComponent } from '@shared/components/time/timewindow.component'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { TimewindowComponent } from '@shared/components/time/timewindow.component'; import { OverlayModule } from '@angular/cdk/overlay'; -// import { TimewindowPanelComponent } from '@shared/components/time/timewindow-panel.component'; -// import { TimeintervalComponent } from '@shared/components/time/timeinterval.component'; -// import { DatetimePeriodComponent } from '@shared/components/time/datetime-period.component'; -// import { EnumToArrayPipe } from '@shared/pipe/enum-to-array.pipe'; +import { TimewindowPanelComponent } from '@shared/components/time/timewindow-panel.component'; +import { TimeintervalComponent } from '@shared/components/time/timeinterval.component'; +import { DatetimePeriodComponent } from '@shared/components/time/datetime-period.component'; +import { EnumToArrayPipe } from '@shared/pipe/enum-to-array.pipe'; import { ClipboardModule } from 'ngx-clipboard'; // import { ValueInputComponent } from '@shared/components/value-input.component'; -// import { IntervalCountPipe } from '@shared/pipe/interval-count.pipe'; import { FullscreenDirective } from '@shared/components/fullscreen.directive'; +import { HighlightPipe } from '@shared/pipe/highlight.pipe'; +import {DashboardAutocompleteComponent} from '@shared/components/dashboard-autocomplete.component'; @NgModule({ providers: [ DatePipe, -// MillisecondsToTimeStringPipe, -// EnumToArrayPipe, + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe // IntervalCountPipe, ], entryComponents: [ TbSnackBarComponent, TbAnchorComponent, -// AddEntityDialogComponent, + AddEntityDialogComponent, // AuditLogDetailsDialogComponent, -// TimewindowPanelComponent, + TimewindowPanelComponent, ], declarations: [ FooterComponent, @@ -103,22 +105,23 @@ import { FullscreenDirective } from '@shared/components/fullscreen.directive'; TbSnackBarComponent, BreadcrumbComponent, UserMenuComponent, -// EntitiesTableComponent, -// AddEntityDialogComponent, -// DetailsPanelComponent, -// EntityDetailsPanelComponent, -// ContactComponent, + EntitiesTableComponent, + AddEntityDialogComponent, + DetailsPanelComponent, + EntityDetailsPanelComponent, + ContactComponent, // AuditLogTableComponent, // AuditLogDetailsDialogComponent, -// TimewindowComponent, -// TimewindowPanelComponent, -// TimeintervalComponent, -// DatetimePeriodComponent, + TimewindowComponent, + TimewindowPanelComponent, + TimeintervalComponent, + DatetimePeriodComponent, // ValueInputComponent, + DashboardAutocompleteComponent, NospacePipe, -// MillisecondsToTimeStringPipe, -// EnumToArrayPipe, -// IntervalCountPipe + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe ], imports: [ CommonModule, @@ -169,16 +172,17 @@ import { FullscreenDirective } from '@shared/components/fullscreen.directive'; TbCheckboxComponent, BreadcrumbComponent, UserMenuComponent, -// EntitiesTableComponent, -// AddEntityDialogComponent, -// DetailsPanelComponent, -// EntityDetailsPanelComponent, -// ContactComponent, + EntitiesTableComponent, + AddEntityDialogComponent, + DetailsPanelComponent, + EntityDetailsPanelComponent, + ContactComponent, // AuditLogTableComponent, -// TimewindowComponent, -// TimewindowPanelComponent, -// TimeintervalComponent, -// DatetimePeriodComponent, + TimewindowComponent, + TimewindowPanelComponent, + TimeintervalComponent, + DatetimePeriodComponent, + DashboardAutocompleteComponent, // ValueInputComponent, MatButtonModule, MatCheckboxModule, @@ -215,9 +219,9 @@ import { FullscreenDirective } from '@shared/components/fullscreen.directive'; ReactiveFormsModule, OverlayModule, NospacePipe, -// MillisecondsToTimeStringPipe, -// EnumToArrayPipe, -// IntervalCountPipe, + MillisecondsToTimeStringPipe, + EnumToArrayPipe, + HighlightPipe, TranslateModule ] }) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 88884094c8..f5e115aafb 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -590,6 +590,7 @@ "add-datasource-prompt": "Please add datasource" }, "details": { + "details": "Details", "edit-mode": "Edit mode", "toggle-edit-mode": "Toggle edit mode" }, @@ -1378,6 +1379,7 @@ "delete-tenants-title": "Are you sure you want to delete { count, plural, 1 {1 tenant} other {# tenants} }?", "delete-tenants-action-title": "Delete { count, plural, 1 {1 tenant} other {# tenants} }", "delete-tenants-text": "Be careful, after the confirmation all selected tenants will be removed and all related data will become unrecoverable.", + "created-time": "Created time", "title": "Title", "title-required": "Title is required.", "description": "Description", @@ -1437,6 +1439,7 @@ "delete-users-text": "Be careful, after the confirmation all selected users will be removed and all related data will become unrecoverable.", "activation-email-sent-message": "Activation email was successfully sent!", "resend-activation": "Resend activation", + "created-time": "Created time", "email": "Email", "email-required": "Email is required.", "invalid-email-format": "Invalid email format.", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index 4fd2be2efb..2465365563 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -152,7 +152,7 @@ "aknowledge-alarm-title": "Reconocer alarma", "aknowledge-alarm-text": "¿Está seguro que quiere reconocer la alarma?", "clear-alarms-title": "Quitar { count, plural, 1 {1 alarma} other {# alarmas} }", - "clear-alarms-text": "¿Está seguro de que desea quitar { count, plural, 1 {1 alarma} other {# alarmas}?", + "clear-alarms-text": "¿Está seguro de que desea quitar { count, plural, 1 {1 alarma} other {# alarmas} }?", "clear-alarm-title": "Quitar alarma", "clear-alarm-text": "¿Está seguro que quiere quitar la alarma?", "alarm-status-filter": "Filtro de estado de alarma" diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 1fdec0d6d5..9603442080 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -88,16 +88,16 @@ "alarm": { "ack-time": "Heure d'acquittement", "acknowledge": "Acquitter", - "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter {count, plural, 1 {1 alarme} other {# alarmes}}?", - "aknowledge-alarms-title": "Acquitter {count, plural, 1 {1 alarme} other {# alarmes}}", + "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter { count, plural, 1 {1 alarme} other {# alarmes} }?", + "aknowledge-alarms-title": "Acquitter { count, plural, 1 {1 alarme} other {# alarmes} }", "alarm": "Alarme", "alarm-details": "Détails de l'alarme", "alarm-required": "Une alarme est requise", "alarm-status": "Etat d'alarme", "alarms": "Alarmes", "clear": "Effacer", - "clear-alarms-text": "Êtes-vous sûr de vouloir effacer {count, plural, 1 {1 alarme} other {# alarmes}}?", - "clear-alarms-title": "Effacer {count, plural, 1 {1 alarme} other {# alarmes}}", + "clear-alarms-text": "Êtes-vous sûr de vouloir effacer { count, plural, 1 {1 alarme} other {# alarmes} }?", + "clear-alarms-title": "Effacer { count, plural, 1 {1 alarme} other {# alarmes} }", "clear-time": "Heure d'éffacement", "created-time": "Heure de création", "details": "Détails", @@ -125,7 +125,7 @@ "UNACK": "non acquittée" }, "select-alarm": "Sélectionnez une alarme", - "selected-alarms": "{count, plural, 1 {1 alarme} other {# alarmes}} sélectionnées", + "selected-alarms": "{ count, plural, 1 {1 alarme} other {# alarmes} } sélectionnées", "severity": "Gravitée", "severity-critical": "Critique", "severity-indeterminate": "indéterminée", @@ -192,7 +192,7 @@ "assign-asset-to-customer": "Attribuer des Assets au client", "assign-asset-to-customer-text": "Veuillez sélectionner les Assets à attribuer au client", "assign-assets": "Attribuer des Assets", - "assign-assets-text": "Attribuer {count, plural, 1 {1 asset} other {# assets}} au client", + "assign-assets-text": "Attribuer { count, plural, 1 {1 asset} other {# assets} } au client", "assign-new-asset": "Attribuer un nouvel Asset", "assign-to-customer": "Attribuer au client", "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les Assets", @@ -202,9 +202,9 @@ "delete-asset-text": "Faites attention, après la confirmation, l'Asset et toutes les données associées deviendront irrécupérables.", "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'Asset '{{assetName}}'?", "delete-assets": "Supprimer des Assets", - "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets}}", + "delete-assets-action-title": "Supprimer { count, plural, 1 {1 asset} other {# assets} }", "delete-assets-text": "Attention, après la confirmation, tous les Assets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-assets-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 asset} other {# assets}}?", + "delete-assets-title": "Etes-vous sûr de vouloir supprimer { count, plural, 1 {1 asset} other {# assets} }?", "description": "Description", "details": "Détails", "enter-asset-type": "Entrez le type d'Asset", @@ -232,9 +232,9 @@ "unassign-asset-text": "Après la confirmation, l'Asset sera non attribué et ne sera pas accessible au client.", "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'Asset '{{assetName}}'?", "unassign-assets": "Retirer les Assets", - "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets}} du client", + "unassign-assets-action-title": "Retirer { count, plural, 1 {1 asset} other {# assets} } du client", "unassign-assets-text": "Après la confirmation, tous les Assets sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de {count, plural, 1 {1 asset} other {# assets}}?", + "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de { count, plural, 1 {1 asset} other {# assets} }?", "unassign-from-customer": "Retirer du client", "view-assets": "Afficher les Assets" }, @@ -246,7 +246,7 @@ "attributes-scope": "Etendue des attributs d'entité", "delete-attributes": "Supprimer les attributs", "delete-attributes-text": "Attention, après la confirmation, tous les attributs sélectionnés seront supprimés.", - "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 attribut} other {# attributs}}?", + "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer { count, plural, 1 {1 attribut} other {# attributs} }?", "enter-attribute-value": "Entrez la valeur de l'attribut", "key": "Clé", "key-required": "La Clé d'attribut est requise.", @@ -258,8 +258,8 @@ "scope-latest-telemetry": "Dernière télémétrie", "scope-server": "Attributs du serveur", "scope-shared": "Attributs partagés", - "selected-attributes": "{count, plural, 1 {1 attribut} other {# attributs}} sélectionnés", - "selected-telemetry": "{count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie}} sélectionnées", + "selected-attributes": "{ count, plural, 1 {1 attribut} other {# attributs} } sélectionnés", + "selected-telemetry": "{ count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie} } sélectionnées", "show-on-widget": "Afficher sur le widget", "value": "Valeur", "value-required": "La valeur d'attribut est obligatoire.", @@ -358,9 +358,9 @@ "delete": "Supprimer le client", "delete-customer-text": "Faites attention, après la confirmation, le client et toutes les données associées deviendront irrécupérables.", "delete-customer-title": "Êtes-vous sûr de vouloir supprimer le client '{{customerTitle}}'?", - "delete-customers-action-title": "Supprimer {count, plural, 1 {1 client} other {# clients}}", + "delete-customers-action-title": "Supprimer { count, plural, 1 {1 client} other {# clients} }", "delete-customers-text": "Faites attention, après la confirmation, tous les clients sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-customers-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 client} other {# clients}}?", + "delete-customers-title": "Êtes-vous sûr de vouloir supprimer { count, plural, 1 {1 client} other {# clients} }?", "description": "Description", "details": "Détails", "devices": "Dispositifs du client", @@ -397,7 +397,7 @@ "assign-dashboard-to-customer": "Attribuer des tableaux de bord au client", "assign-dashboard-to-customer-text": "Veuillez sélectionner les tableaux de bord à affecter au client", "assign-dashboards": "Attribuer des tableaux de bord", - "assign-dashboards-text": "Attribuer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} aux clients", + "assign-dashboards-text": "Attribuer { count, plural, 1 {1 tableau de bord} other {# tableaux de bord} } aux clients", "assign-new-dashboard": "Attribuer un nouveau tableau de bord", "assign-to-customer": "Attribuer au client", "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les tableaux de bord", @@ -428,9 +428,9 @@ "delete-dashboard-text": "Faites attention, après la confirmation, le tableau de bord et toutes les données associées deviendront irrécupérables.", "delete-dashboard-title": "Êtes-vous sûr de vouloir supprimer le tableau de bord '{{dashboardTitle}}'?", "delete-dashboards": "Supprimer les tableaux de bord", - "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}", + "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord} }", "delete-dashboards-text": "Attention, après la confirmation, tous les tableaux de bord sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord} }?", "delete-state": "Supprimer l'état du tableau de bord", "delete-state-text": "Etes-vous sûr de vouloir supprimer l'état du tableau de bord avec le nom '{{stateName}}'?", "delete-state-title": "Supprimer l'état du tableau de bord", @@ -493,7 +493,7 @@ "select-state": "Sélectionnez l'état cible", "select-widget-subtitle": "Liste des types de widgets disponibles", "select-widget-title": "Sélectionner un widget", - "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord}} sélectionnés", + "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord} } sélectionnés", "set-background": "Définir l'arrière-plan", "settings": "Paramètres", "show-details": "Afficher les détails", @@ -515,10 +515,10 @@ "unassign-dashboard-text": "Après la confirmation, le tableau de bord ne sera pas attribué et ne sera pas accessible au client.", "unassign-dashboard-title": "Êtes-vous sûr de vouloir annuler l'affectation du tableau de bord '{{dashboardTitle}}'?", "unassign-dashboards": "Retirer les tableaux de bord", - "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} des clients", - "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} du client", + "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord} } des clients", + "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord} } du client", "unassign-dashboards-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord} }?", "unassign-from-customer": "Retirer du client", "unassign-from-customers": "Retirer les tableaux de bord des clients", "unassign-from-customers-text": "Veuillez sélectionner les clients à annuler l'affectation du ou des tableaux de bord", @@ -541,8 +541,8 @@ "function-types": "Types de fonctions", "function-types-required": "Les types de fonctions sont obligatoires", "label": "Label", - "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés}}", - "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés}}", + "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés} }", + "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés} }", "settings": "Paramètres", "timeseries": "Timeseries", "timeseries-or-attributes-required": "Les timeseries / attributs d'entité sont obligatoires.", @@ -580,7 +580,7 @@ "assign-device-to-customer": "Affecter des dispositifs au client", "assign-device-to-customer-text": "Veuillez sélectionner les dispositif à affecter au client", "assign-devices": "Attribuer des dispositifs", - "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs}} au client", + "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs} } au client", "assign-new-device": "Attribuer un nouveau dispositif", "assign-to-customer": "Attribuer au client", "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les dispositifs", @@ -596,9 +596,9 @@ "delete-device-text": "Faites attention, après la confirmation, le dispositif et toutes les données associées deviendront irrécupérables.", "delete-device-title": "Êtes-vous sûr de vouloir supprimer le dispositif '{{deviceName}}'?", "delete-devices": "Supprimer les dispositifs", - "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}", + "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs} }", "delete-devices-text": "Faites attention, après la confirmation, tous les dispositifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs} }?", "description": "Description", "details": "Détails", "device": "Dispositif", @@ -653,9 +653,9 @@ "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", "unassign-devices": "Annuler l'affectation des dispositifs", - "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs}} du client", + "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs} } du client", "unassign-devices-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par le client.", - "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs} }?", "unassign-from-customer": "Retirer du client", "use-device-name-filter": "Utiliser le filtre", "view-credentials": "Afficher les informations d'identification", @@ -696,17 +696,17 @@ "entity-types": "Types d'entité", "key": "Clé", "key-name": "Nom de la clé", - "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes}}", - "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets}}", - "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients}}", - "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord}}", - "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs}}", - "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins}}", - "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles}}", - "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles}}", - "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles}}", - "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants}}", - "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs}}", + "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes} }", + "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets} }", + "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients} }", + "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord} }", + "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs} }", + "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins} }", + "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles} }", + "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles} }", + "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles} }", + "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants} }", + "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs} }", "missing-entity-filter-error": "Le filtre est manquant pour l'alias '{{alias}}'.", "name-starts-with": "Nom commence par", "no-alias-matching": "'{{alias}}' introuvable.", @@ -724,7 +724,7 @@ "rulenode-name-starts-with": "Les noeuds de règles dont le nom commence par '{{prefix}}'", "search": "Recherche d'entités", "select-entities": "Sélectionner des entités", - "selected-entities": "{count, plural, 1 {1 entité} other {# entités}} sélectionnées", + "selected-entities": "{count, plural, 1 {1 entité} other {# entités} } sélectionnées", "tenant-name-starts-with": "Les Tenant dont le nom commence par '{{prefix}}'", "type": "Type", "type-alarm": "Alarme", @@ -832,7 +832,7 @@ "delete-extension-text": "Attention, après la confirmation, l'extension et toutes les données associées deviendront irrécupérables.", "delete-extension-title": "Êtes-vous sûr de vouloir supprimer l'extension '{{extensionId}}'?", "delete-extensions-text": "Attention, après la confirmation, toutes les extensions sélectionnées seront supprimées.", - "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions}}?", + "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions} }?", "device-name-expression": "expression du nom du dispositif", "device-name-filter": "Filtre de nom de dispositif", "device-type-expression": "expression de type de dispositif", @@ -918,7 +918,7 @@ "response-timeout": "Délai de réponse en millisecondes", "response-topic-expression": "Expression du topic de la réponse", "retry-interval": "Intervalle de nouvelle tentative en millisecondes", - "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions}} sélectionné", + "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions} } sélectionné", "server-side-rpc": "RPC côté serveur", "ssl": "Ssl", "sync": { @@ -960,9 +960,9 @@ "delete-item-text": "Faites attention, après la confirmation, cet élément et toutes les données associées deviendront irrécupérables.", "delete-item-title": "Êtes-vous sûr de vouloir supprimer cet élément?", "delete-items": "Supprimer les éléments", - "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments}}", + "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments} }", "delete-items-text": "Attention, après la confirmation, tous les éléments sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments}}?", + "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments} }?", "item-details": "Détails de l'élément", "no-items-text": "Aucun élément trouvé", "scroll-to-top": "Défiler vers le haut" @@ -1080,11 +1080,11 @@ "delete-from-relation-text": "Attention, après la confirmation, l'entité actuelle ne sera pas liée à l'entité '{{entityName}}'.", "delete-from-relation-title": "Etes-vous sûr de vouloir supprimer la relation de l'entité '{{entityName}}'?", "delete-from-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et l'entité actuelle ne sera pas liée aux entités correspondantes.", - "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations} }?", "delete-to-relation-text": "Attention, après la confirmation, l'entité '{{entityName}} ne sera plus liée à l'entité actuelle.", "delete-to-relation-title": "Êtes-vous sûr de vouloir supprimer la relation avec l'entité '{{entityName}}'?", "delete-to-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et les entités correspondantes ne seront pas liées à l'entité en cours.", - "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations} }?", "direction": "Sens", "direction-type": { "FROM": "de", @@ -1105,7 +1105,7 @@ "FROM": "De", "TO": "À" }, - "selected-relations": "{count, plural, 1 {1 relation} other {# relations}} sélectionné", + "selected-relations": "{count, plural, 1 {1 relation} other {# relations} } sélectionné", "to-entity": "À l'entité", "to-entity-name": "vers le nom de l'entité", "to-entity-type": "Vers le type d'entité", @@ -1121,9 +1121,9 @@ "delete": "Supprimer la chaîne de règles", "delete-rulechain-text": "Attention, après la confirmation, la chaîne de règles et toutes les données associées deviendront irrécupérables.", "delete-rulechain-title": "Voulez-vous vraiment supprimer la chaîne de règles '{{ruleChainName}}'?", - "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}", + "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles} }", "delete-rulechains-text": "Attention, après la confirmation, toutes les chaînes de règles sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", - "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}?", + "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles} }?", "description": "Description", "details": "Détails", "events": "Evénements", @@ -1220,9 +1220,9 @@ "delete": "Supprimer le Tenant", "delete-tenant-text": "Attention, après la confirmation, le Tenant et toutes les données associées deviendront irrécupérables.", "delete-tenant-title": "Etes-vous sûr de vouloir supprimer le tenant '{{tenantTitle}}'?", - "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants}}", + "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants} }", "delete-tenants-text": "Attention, après la confirmation, tous les Tenants sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants}}?", + "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants} }?", "description": "Description", "details": "Détails", "events": "Événements", @@ -1242,26 +1242,26 @@ "timeinterval": { "advanced": "Avancé", "days": "Jours", - "days-interval": "{days, plural, 1 {1 jour} other {# jours}}", + "days-interval": "{days, plural, 1 {1 jour} other {# jours} }", "hours": "Heures", - "hours-interval": "{hours, plural, 1 {1 heure} other {# heures}}", + "hours-interval": "{hours, plural, 1 {1 heure} other {# heures} }", "minutes": "Minutes", - "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes}}", + "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes} }", "seconds": "Secondes", - "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes}}" + "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes} }" }, "timewindow": { "date-range": "Plage de dates", - "days": "{days, plural, 1 {jour} other {# jours}}", + "days": "{days, plural, 1 {jour} other {# jours} }", "edit": "Modifier timewindow", "history": "Historique", - "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures}}", + "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures} }", "last": "Dernier", "last-prefix": "dernier", - "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes}}", + "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes} }", "period": "de {{startTime}} à {{endTime}}", "realtime": "Temps réel", - "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds}}", + "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds} }", "time-period": "Période" }, "user": { @@ -1281,9 +1281,9 @@ "delete": "Supprimer l'utilisateur", "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", "delete-user-title": "Etes-vous sûr de vouloir supprimer l'utilisateur '{{userEmail}}'?", - "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}", + "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs} }", "delete-users-text": "Attention, après la confirmation, tous les utilisateurs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}?", + "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs} }?", "description": "Description", "details": "Détails", "display-activation-link": "Afficher le lien d'activation", @@ -1417,7 +1417,7 @@ "general-settings": "Paramètres généraux", "height": "Hauteur", "margin": "Marge", - "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés}}", + "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés} }", "mobile-mode-settings": "Paramètres du mode mobile", "order": "Ordre", "padding": "Padding", @@ -1511,9 +1511,9 @@ "delete": "Supprimer le groupe de widgets", "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}", + "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets} }", "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}?", + "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets} }?", "details": "Détails", "empty": "Le groupe de widgets est vide", "export": "Exporter le groupe de widgets", diff --git a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json index 504344c749..ae69c79681 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json +++ b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json @@ -626,7 +626,7 @@ "delete-device-title": "Вы точно хотите удалить устройство '{{deviceName}}'?", "delete-device-text": "Внимание, после подтверждения устройство и все связанные с ним данные будут безвозвратно утеряны.", "delete-devices-title": "Вы точно хотите удалить { count, plural, one {1 устройство} few {# устройства} other {# устройств} }?", - "delete-devices-action-title": "Удалить { count, plural, one {1 устройство} few {# устройства} other {# устройств} } }", + "delete-devices-action-title": "Удалить { count, plural, one {1 устройство} few {# устройства} other {# устройств} }", "delete-devices-text": "Внимание, после подтверждения выбранные устройства и все связанные с ними данные будут безвозвратно утеряны..", "unassign-device-title": "Вы точно хотите отозвать устройство '{{deviceName}}'?", "unassign-device-text": "После подтверждения устройство будет недоступно клиенту.", @@ -1064,7 +1064,7 @@ "delete-item-title": "Вы точно хотите удалить этот объект?", "delete-item-text": "Внимание, после подтверждения объект и все связанные с ним данные будут безвозвратно утеряны.", "delete-items-title": "Вы точно хотите удалить { count, plural, one {1 объект} few {# объекта} other {# объектов} }?", - "delete-items-action-title": "Удалить { count, plural, one {1 объект} few {# объекта} other {# объектов}", + "delete-items-action-title": "Удалить { count, plural, one {1 объект} few {# объекта} other {# объектов} }", "delete-items-text": "Внимание, после подтверждения выбранные объекты и все связанные с ними данные будут безвозвратно утеряны.", "add-item-text": "Добавить новый объект", "no-items-text": "Объекты не найдены", @@ -1646,4 +1646,4 @@ "cs_CZ": "Чешский" } } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index e3726ec841..0187b348e0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -419,7 +419,7 @@ "assign-dashboards": "Kontrol panelleri ata", "assign-new-dashboard": "Yeni kontrol paneli ata", "assign-dashboards-text": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } kullanıcı grubuna ata", - "unassign-dashboards-action-text": "Müşterilerden atama { count, plural, 1 {1 gösterge tablosu} other {# panolar}}", + "unassign-dashboards-action-text": "Müşterilerden atama { count, plural, 1 {1 gösterge tablosu} other {# panolar} }", "delete-dashboards": "Kontrol panellerini sil", "unassign-dashboards": "Kontrol panellerinden atamayı kaldır", "unassign-dashboards-action-title": "{ count, plural, 1 {1 kontrol panelinin} other {# kontrol panelinin} } atamaları kullanıcı grubundan kaldır", @@ -710,7 +710,7 @@ "asset-name-starts-with": "İsmi '{{prefix}}' ile başlayan varlıklar", "type-entity-view": "Varlık Görünümü", "type-entity-views": "Varlık Görünümleri", - "list-of-entity-views": "{ count, plural, 1 {Bir varlık görünümü} other {# varlık görüntüleme}} listesi", + "list-of-entity-views": "{ count, plural, 1 {Bir varlık görünümü} other {# varlık görüntüleme} } listesi", "entity-view-name-starts-with": "Adı {{önek}} ile başlayan varlık görünümleri", "type-rule": "Kural", "type-rules": "Kurallar", @@ -742,11 +742,11 @@ "alarm-name-starts-with": "İsmi '{{prefix}}' ile başlayan alarmlar", "type-rulechain": "Kural zinciri", "type-rulechains": "Kural zincirleri", - "list-of-rulechains": "{ count, plural, 1 {Bir kural zinciri} other {# kural zincirinin listesi}}", + "list-of-rulechains": "{ count, plural, 1 {Bir kural zinciri} other {# kural zincirinin listesi} }", "rulechain-name-starts-with": "İsimleri {{prefix}} ile başlayan kural zincirleri", "type-rulenode": "Kural düğümü", "type-rulenodes": "Kural düğümleri", - "list-of-rulenodes": "{ count, plural, 1 {Bir kural node} other {# kural düğümünün listesi}}", + "list-of-rulenodes": "{ count, plural, 1 {Bir kural node} other {# kural düğümünün listesi} }", "rulenode-name-starts-with": "İsimleri '{{prefix}} ile başlayan kural düğümleri", "type-current-customer": "Mevcut Müşteri", "search": "Öğeleri ara", @@ -765,7 +765,7 @@ "aliases": "Varlık Görünümü takma adları", "no-alias-matching": "'{{alias}} bulunamadı. ", "no-aliases-found": "Takma ad bulunamadı", - "no-key-matching": "'{{anahtar bulunamadı.", + "no-key-matching": "'{{key}}' bulunamadı.", "no-keys-found": "Anahtar bulunamadı.", "create-new-alias": "Yeni bir tane oluştur!", "create-new-key": "Yeni bir tane oluştur!", @@ -792,21 +792,21 @@ "add-entity-view-text": "Yeni varlık görünümü ekle", "delete": "Varlık görünümünü sil", "assign-entity-views": "Varlık görünümleri atama", - "assign-entity-views-text": "Müşteriye { count, plural, 1 {1 entityView} other {# entityViews}} atayın ", + "assign-entity-views-text": "Müşteriye { count, plural, 1 {1 entityView} other {# entityViews} } atayın ", "delete-entity-views": "Varlık görünümlerini sil", "unassign-from-customer": "Müşteriden atama", "unassign-entity-views": "Varlık görünümlerini atama", - "unassign-entity-views-action-title": "Müşteriden atama { count, plural, 1 {1 entityView} other {# entityViews}}", + "unassign-entity-views-action-title": "Müşteriden atama { count, plural, 1 {1 entityView} other {# entityViews} }", "assign-new-entity-view": "Yeni varlık görünümü atama", "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{entityViewName}} '? ", "delete-entity-view-text": "Dikkatli olun, onaylandıktan sonra varlık görünümü ve ilgili tüm veriler kurtarılamayacak.", - "delete-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", - "delete-entity-views-action-title": "Sil { count, plural, 1 {1 entityView} other {# entityViews}}", + "delete-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews} } varlık görünümüne sahip olmak istediğinizden emin misiniz?", + "delete-entity-views-action-title": "Sil { count, plural, 1 {1 entityView} other {# entityViews} }", "delete-entity-views-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen görünümler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{entityViewName}} '? ", "unassign-entity-view-text": "Onaydan sonra varlık görünümü atanmamış olacak ve müşteri tarafından erişilemeyecektir.", "unassign-entity-view": "Varlık görünümünün atamasını kaldır", - "unassign-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews} } hesabının atamasını kaldırmak istediğinizden emin misiniz?", "unassign-entity-views-text": "Onaylandıktan sonra, seçilen tüm öğe görünümleri atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", "entity-view-type": "Varlık Görünümü türü", "entity-view-type-required": "Varlık Görünümü türü gerekli.", @@ -861,7 +861,7 @@ }, "extension": { "extensions": "Uzantılar", - "selected-extensions": "{ count, plural, 1 {1 uzantı} other {# extensions}} seçildi", + "selected-extensions": "{ count, plural, 1 {1 uzantı} other {# extensions} } seçildi", "type": "Tür", "key": "Anahtar", "value": "Değer", @@ -875,7 +875,7 @@ "edit": "Uzantıyı düzenle", "delete-extension-title": "{{ExtensionId}} uzantısını silmek istediğinizden emin misiniz? ", "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantı ve ilgili tüm veriler kurtarılamaz.", - "delete-extensions-title": "{ count, plural, 1 {1 uzantı} other {# extensions}} silmek istediğinizden emin misiniz?", + "delete-extensions-title": "{ count, plural, 1 {1 uzantı} other {# extensions} } silmek istediğinizden emin misiniz?", "delete-extensions-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen uzantılar kaldırılacak.", "converters": "Dönüştürücü", "converter-id": "Dönüştürücü kimliği", @@ -1606,4 +1606,4 @@ "cs_CZ": "Çekçe" } } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index 8c2e9c6def..082f8a9b41 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -457,8 +457,8 @@ "customer-details": "Інформація про клієнта", "delete-customer-title": "Ви впевнені, що хочете видалити клієнта '{{customerTitle}}'?", "delete-customer-text": "Будьте обережні, після підтвердження, клієнт та всі пов'язані з ним дані, стануть недоступними.", - "delete-customers-title": "Ви впевнені, що хочете видалити {count, plural, 1 {1 клієнт}, інші {# клієнти}}?", - "delete-customers-action-title": "Видалити{ count, plural, 1 {1 клієнт} other {# клієнти} }", + "delete-customers-title": "Ви впевнені, що хочете видалити { count, plural, 1 {1 клієнт} other {# клієнти} }?", + "delete-customers-action-title": "Видалити { count, plural, 1 {1 клієнт} other {# клієнти} }", "delete-customers-text": "Будьте обережні, після підтвердження, всі вибрані клієнти будуть видалені і всі пов'язані з ними дані, стануть недоступними.", "manage-users": "Керування користувачами", "manage-assets": "Керування активами", @@ -474,7 +474,7 @@ "select-customer": "Виберіть клієнта", "no-customers-matching": "Клієнтів, які відповідають '{{entity}}' не знайдено.", "customer-required": "Необхідно задати клієнта", - "selected-customers": "{ count, plural, 1 {1 клієнт} інші {# клієнти} } вибрано", + "selected-customers": "{ count, plural, 1 {1 клієнт} other {# клієнти} } вибрано", "search": "Пошук клієнтів", "select-group-to-add": "Виберіть цільову групу, щоб додати вибраних клієнтів", "select-group-to-move": "Виберіть цільову групу для переміщення вибраних клієнтів", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index a683828a8a..e75317aca1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -596,11 +596,11 @@ "manage-credentials": "管理凭据", "delete": "删除设备", "assign-devices": "分配设备", - "assign-devices-text": "将{count,plural,1 {1 设备} other {# 设备}}分配给客户", + "assign-devices-text": "将{count,plural,1 {1 设备} other {# 设备} }分配给客户", "delete-devices": "删除设备", "unassign-from-customer": "取消分配客户", "unassign-devices": "取消分配设备", - "unassign-devices-action-title": "从客户处取消分配{count,plural,1 {1 设备} other {# 设备}}", + "unassign-devices-action-title": "从客户处取消分配{count,plural,1 {1 设备} other {# 设备} }", "assign-new-device": "分配新设备", "make-public-device-title": "您确定要将设备 '{{deviceName}}' 设为公开吗?", "make-public-device-text": "确认后,设备及其所有数据将被设为公开并可被其他人访问。", @@ -609,13 +609,13 @@ "view-credentials": "查看凭据", "delete-device-title": "您确定要删除设备的{{deviceName}}吗?", "delete-device-text": "小心!确认后设备及其所有相关数据将不可恢复。", - "delete-devices-title": "您确定要删除{count,plural,1 {1 设备} other {# 设备}} 吗?", - "delete-devices-action-title": "删除 {count,plural,1 {1 设备} other {# 设备}}", + "delete-devices-title": "您确定要删除{count,plural,1 {1 设备} other {# 设备} } 吗?", + "delete-devices-action-title": "删除 {count,plural,1 {1 设备} other {# 设备} }", "delete-devices-text": "小心!确认后所有选定的设备将被删除,所有相关数据将不可恢复。", "unassign-device-title": "您确定要取消分配设备 '{{deviceName}}'?", "unassign-device-text": "确认后,设备将被取消分配,客户将无法访问。", "unassign-device": "取消分配设备", - "unassign-devices-title": "您确定要取消分配{count,plural,1 {1 设备} other {# 设备}} 吗?", + "unassign-devices-title": "您确定要取消分配{count,plural,1 {1 设备} other {# 设备} } 吗?", "unassign-devices-text": "确认后,所有选定的设备将被取消分配,并且客户将无法访问。", "device-credentials": "设备凭据", "credentials-type": "凭据类型", @@ -792,7 +792,7 @@ "delete-entity-views": "删除实体视图", "unassign-from-customer": "取消分配客户", "unassign-entity-views": "取消分配实体视图", - "unassign-entity-views-action-title": "从客户处取消分配{count,plural,1 {1 实体视图} other {# 实体视图}}", + "unassign-entity-views-action-title": "从客户处取消分配{count,plural,1 {1 实体视图} other {# 实体视图} }", "assign-new-entity-view": "分配新实体视图", "delete-entity-view-title": "确定要删除实体视图 '{{entityViewName}}'?", "delete-entity-view-text": "小心!确认后实体视图及其所有相关数据将不可恢复。", @@ -1192,7 +1192,7 @@ "set-root-rulechain-text": "确认之后,规则链将变为根规格链,并将处理所有传入的传输消息。", "delete-rulechain-title": " 确实要删除规则链'{{ruleChainName}}'吗?", "delete-rulechain-text": "小心,在确认规则链和所有相关数据将变得不可恢复。", - "delete-rulechains-title": "确实要删除{count, plural, 1 { 1 规则链}其他{# 规则链库}}吗?", + "delete-rulechains-title": "确实要删除{count, plural, 1 { 1 规则链} other {# 规则链库} }吗?", "delete-rulechains-action-title": "删除 { count, plural, 1 {1 规则链} other {# 规则链库} }", "delete-rulechains-text": "小心,确认后,所有选定的规则链将被删除,所有相关的数据将变得不可恢复。", "add-rulechain-text": "添加新的规则链", @@ -1283,7 +1283,7 @@ "tenant-details": "租客详情", "delete-tenant-title": "您确定要删除租户'{{tenantTitle}}'吗?", "delete-tenant-text": "小心!确认后,租户和所有相关数据将不可恢复。", - "delete-tenants-title": "您确定要删除 {count,plural,1 {1 租户} other {# 租户}} 吗?", + "delete-tenants-title": "您确定要删除 {count,plural,1 {1 租户} other {# 租户} } 吗?", "delete-tenants-action-title": "删除 { count, plural, 1 {1 租户} other {# 租户} }", "delete-tenants-text": "小心!确认后,所有选定的租户将被删除,所有相关数据将不可恢复。", "title": "标题",