Commit 641db71ce6b834ec7c194b94a560e32b25da45a2

Authored by Igor Kulikov
1 parent 9e9072de

Configure UI help assets base url.

Showing 46 changed files with 201 additions and 58 deletions
@@ -96,7 +96,7 @@ public class DashboardController extends BaseController { @@ -96,7 +96,7 @@ public class DashboardController extends BaseController {
96 public static final String DASHBOARD_DEFINITION = "The Dashboard object is a heavyweight object that contains information about the dashboard (e.g. title, image, assigned customers) and also configuration JSON (e.g. layouts, widgets, entity aliases)."; 96 public static final String DASHBOARD_DEFINITION = "The Dashboard object is a heavyweight object that contains information about the dashboard (e.g. title, image, assigned customers) and also configuration JSON (e.g. layouts, widgets, entity aliases).";
97 public static final String HIDDEN_FOR_MOBILE = "Exclude dashboards that are hidden for mobile"; 97 public static final String HIDDEN_FOR_MOBILE = "Exclude dashboards that are hidden for mobile";
98 98
99 - @Value("${dashboard.max_datapoints_limit}") 99 + @Value("${ui.dashboard.max_datapoints_limit}")
100 private long maxDatapointsLimit; 100 private long maxDatapointsLimit;
101 101
102 @ApiOperation(value = "Get server time (getServerTime)", 102 @ApiOperation(value = "Get server time (getServerTime)",
  1 +/**
  2 + * Copyright © 2016-2021 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +package org.thingsboard.server.controller;
  17 +
  18 +import io.swagger.annotations.ApiOperation;
  19 +import org.springframework.beans.factory.annotation.Value;
  20 +import org.springframework.security.access.prepost.PreAuthorize;
  21 +import org.springframework.web.bind.annotation.RequestMapping;
  22 +import org.springframework.web.bind.annotation.RequestMethod;
  23 +import org.springframework.web.bind.annotation.ResponseBody;
  24 +import org.springframework.web.bind.annotation.RestController;
  25 +import org.thingsboard.server.common.data.exception.ThingsboardException;
  26 +import org.thingsboard.server.queue.util.TbCoreComponent;
  27 +
  28 +@RestController
  29 +@TbCoreComponent
  30 +@RequestMapping("/api")
  31 +public class UiSettingsController extends BaseController {
  32 +
  33 + @Value("${ui.help.base-url}")
  34 + private String helpBaseUrl;
  35 +
  36 + @ApiOperation(value = "Get UI help base url (getHelpBaseUrl)",
  37 + notes = "Get UI help base url used to fetch help assets. " +
  38 + "The actual value of the base url is configurable in the system configuration file.")
  39 + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
  40 + @RequestMapping(value = "/uiSettings/helpBaseUrl", method = RequestMethod.GET)
  41 + @ResponseBody
  42 + public String getHelpBaseUrl() throws ThingsboardException {
  43 + return helpBaseUrl;
  44 + }
  45 +
  46 +}
@@ -129,10 +129,16 @@ usage: @@ -129,10 +129,16 @@ usage:
129 check: 129 check:
130 cycle: "${USAGE_STATS_CHECK_CYCLE:60000}" 130 cycle: "${USAGE_STATS_CHECK_CYCLE:60000}"
131 131
132 -# Dashboard parameters  
133 -dashboard:  
134 - # Maximum allowed datapoints fetched by widgets  
135 - max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" 132 +# UI parameters
  133 +ui:
  134 + # Dashboard parameters
  135 + dashboard:
  136 + # Maximum allowed datapoints fetched by widgets
  137 + max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}"
  138 + # Help parameters
  139 + help:
  140 + # Base url for UI help assets
  141 + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard/master/ui-ngx/src/assets}"
136 142
137 database: 143 database:
138 ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by single API call to fetch telemetry records 144 ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by single API call to fetch telemetry records
@@ -70,13 +70,12 @@ frontend http-in @@ -70,13 +70,12 @@ frontend http-in
70 acl transport_http_acl path_beg /api/v1/ 70 acl transport_http_acl path_beg /api/v1/
71 acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ 71 acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/
72 acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ 72 acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/
73 - acl tb_rulenode_assets path_reg ^/assets/help/.*/rulenode/.*$  
74 73
75 redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } 74 redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true }
76 75
77 use_backend letsencrypt_http if letsencrypt_http_acl 76 use_backend letsencrypt_http if letsencrypt_http_acl
78 use_backend tb-http-backend if transport_http_acl 77 use_backend tb-http-backend if transport_http_acl
79 - use_backend tb-api-backend if tb_api_acl or tb_rulenode_assets 78 + use_backend tb-api-backend if tb_api_acl
80 79
81 default_backend tb-web-backend 80 default_backend tb-web-backend
82 81
@@ -89,10 +88,9 @@ frontend https_in @@ -89,10 +88,9 @@ frontend https_in
89 88
90 acl transport_http_acl path_beg /api/v1/ 89 acl transport_http_acl path_beg /api/v1/
91 acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ 90 acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/
92 - acl tb_rulenode_assets path_reg ^/assets/help/.*/rulenode/.*$  
93 91
94 use_backend tb-http-backend if transport_http_acl 92 use_backend tb-http-backend if transport_http_acl
95 - use_backend tb-api-backend if tb_api_acl or tb_rulenode_assets 93 + use_backend tb-api-backend if tb_api_acl
96 94
97 default_backend tb-web-backend 95 default_backend tb-web-backend
98 96
@@ -26,10 +26,6 @@ const PROXY_CONFIG = { @@ -26,10 +26,6 @@ const PROXY_CONFIG = {
26 "target": ruleNodeUiforwardUrl, 26 "target": ruleNodeUiforwardUrl,
27 "secure": false, 27 "secure": false,
28 }, 28 },
29 - "/assets/help/*/rulenode/**": {  
30 - "target": ruleNodeUiforwardUrl,  
31 - "secure": false,  
32 - },  
33 "/static/widgets": { 29 "/static/widgets": {
34 "target": forwardUrl, 30 "target": forwardUrl,
35 "secure": false, 31 "secure": false,
  1 +///
  2 +/// Copyright © 2016-2021 The Thingsboard Authors
  3 +///
  4 +/// Licensed under the Apache License, Version 2.0 (the "License");
  5 +/// you may not use this file except in compliance with the License.
  6 +/// You may obtain a copy of the License at
  7 +///
  8 +/// http://www.apache.org/licenses/LICENSE-2.0
  9 +///
  10 +/// Unless required by applicable law or agreed to in writing, software
  11 +/// distributed under the License is distributed on an "AS IS" BASIS,
  12 +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 +/// See the License for the specific language governing permissions and
  14 +/// limitations under the License.
  15 +///
  16 +
  17 +import { Injectable } from '@angular/core';
  18 +import { HttpClient } from '@angular/common/http';
  19 +import { defaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils';
  20 +import { Observable } from 'rxjs';
  21 +import { publishReplay, refCount } from 'rxjs/operators';
  22 +
  23 +@Injectable({
  24 + providedIn: 'root'
  25 +})
  26 +export class UiSettingsService {
  27 +
  28 + private helpBaseUrlObservable: Observable<string>;
  29 +
  30 + constructor(
  31 + private http: HttpClient
  32 + ) { }
  33 +
  34 + public getHelpBaseUrl(): Observable<string> {
  35 + if (!this.helpBaseUrlObservable) {
  36 + this.helpBaseUrlObservable = this.http.get('/api/uiSettings/helpBaseUrl', {responseType: 'text', ...defaultHttpOptions(true)}).pipe(
  37 + publishReplay(1),
  38 + refCount()
  39 + );
  40 + }
  41 + return this.helpBaseUrlObservable;
  42 + }
  43 +}
@@ -18,23 +18,29 @@ import { Injectable } from '@angular/core'; @@ -18,23 +18,29 @@ import { Injectable } from '@angular/core';
18 import { HttpClient } from '@angular/common/http'; 18 import { HttpClient } from '@angular/common/http';
19 import { TranslateService } from '@ngx-translate/core'; 19 import { TranslateService } from '@ngx-translate/core';
20 import { Observable, of } from 'rxjs'; 20 import { Observable, of } from 'rxjs';
21 -import { catchError, mergeMap, tap } from 'rxjs/operators';  
22 -import { helpBaseUrl } from '@shared/models/constants'; 21 +import { catchError, map, mergeMap, tap } from 'rxjs/operators';
  22 +import { helpBaseUrl as siteBaseUrl } from '@shared/models/constants';
  23 +import { UiSettingsService } from '@core/http/ui-settings.service';
23 24
24 -const NOT_FOUND_CONTENT = '## Not found'; 25 +const localHelpBaseUrl = '/assets';
  26 +
  27 +const NOT_FOUND_CONTENT: HelpData = {
  28 + content: '## Not found',
  29 + helpBaseUrl: localHelpBaseUrl
  30 +};
25 31
26 @Injectable({ 32 @Injectable({
27 providedIn: 'root' 33 providedIn: 'root'
28 }) 34 })
29 export class HelpService { 35 export class HelpService {
30 36
31 - private helpBaseUrl = helpBaseUrl;  
32 - 37 + private siteBaseUrl = siteBaseUrl;
33 private helpCache: {[lang: string]: {[key: string]: string}} = {}; 38 private helpCache: {[lang: string]: {[key: string]: string}} = {};
34 39
35 constructor( 40 constructor(
36 private translate: TranslateService, 41 private translate: TranslateService,
37 - private http: HttpClient 42 + private http: HttpClient,
  43 + private uiSettingsService: UiSettingsService
38 ) {} 44 ) {}
39 45
40 getHelpContent(key: string): Observable<string> { 46 getHelpContent(key: string): Observable<string> {
@@ -70,13 +76,38 @@ export class HelpService { @@ -70,13 +76,38 @@ export class HelpService {
70 } 76 }
71 } 77 }
72 78
73 - private loadHelpContent(lang: string, key: string): Observable<string> {  
74 - return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} ); 79 + private loadHelpContent(lang: string, key: string): Observable<HelpData> {
  80 + return this.uiSettingsService.getHelpBaseUrl().pipe(
  81 + mergeMap((helpBaseUrl) => {
  82 + return this.loadHelpContentFromBaseUrl(helpBaseUrl, lang, key).pipe(
  83 + catchError((e) => {
  84 + if (localHelpBaseUrl !== helpBaseUrl) {
  85 + return this.loadHelpContentFromBaseUrl(localHelpBaseUrl, lang, key);
  86 + } else {
  87 + throw e;
  88 + }
  89 + })
  90 + );
  91 + })
  92 + );
  93 + }
  94 +
  95 + private loadHelpContentFromBaseUrl(helpBaseUrl: string, lang: string, key: string): Observable<HelpData> {
  96 + return this.http.get(`${helpBaseUrl}/help/${lang}/${key}.md`, {responseType: 'text'} ).pipe(
  97 + map((content) => {
  98 + return {
  99 + content,
  100 + helpBaseUrl
  101 + };
  102 + })
  103 + );
75 } 104 }
76 105
77 - private processVariables(content: string): string {  
78 - const baseUrlReg = /\${baseUrl}/g;  
79 - return content.replace(baseUrlReg, this.helpBaseUrl); 106 + private processVariables(helpData: HelpData): string {
  107 + const baseUrlReg = /\${siteBaseUrl}/g;
  108 + helpData.content = helpData.content.replace(baseUrlReg, this.siteBaseUrl);
  109 + const helpBaseUrlReg = /\${helpBaseUrl}/g;
  110 + return helpData.content.replace(helpBaseUrlReg, helpData.helpBaseUrl);
80 } 111 }
81 112
82 private processIncludes(content: string): Observable<string> { 113 private processIncludes(content: string): Observable<string> {
@@ -96,3 +127,8 @@ export class HelpService { @@ -96,3 +127,8 @@ export class HelpService {
96 } 127 }
97 128
98 } 129 }
  130 +
  131 +interface HelpData {
  132 + content: string;
  133 + helpBaseUrl: string;
  134 +}
ui-ngx/src/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/clear_alarm_node_script_fn.md
@@ -58,11 +58,11 @@ return details; @@ -58,11 +58,11 @@ return details;
58 58
59 <br> 59 <br>
60 60
61 -More details about Alarms can be found in [this tutorial{:target="_blank"}](${baseUrl}/docs/user-guide/alarms/). 61 +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/).
62 62
63 You can see the real life example, where this node is used, in the next tutorial: 63 You can see the real life example, where this node is used, in the next tutorial:
64 64
65 -- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) 65 +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/)
66 66
67 <br> 67 <br>
68 <br> 68 <br>
ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/common_node_script_args.md
ui-ngx/src/assets/help/en_US/rulenode/create_alarm_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/create_alarm_node_script_fn.md
@@ -59,11 +59,11 @@ return details; @@ -59,11 +59,11 @@ return details;
59 59
60 <br> 60 <br>
61 61
62 -More details about Alarms can be found in [this tutorial{:target="_blank"}](${baseUrl}/docs/user-guide/alarms/). 62 +More details about Alarms can be found in [this tutorial{:target="_blank"}](${siteBaseUrl}/docs/user-guide/alarms/).
63 63
64 You can see the real life example, where this node is used, in the next tutorial: 64 You can see the real life example, where this node is used, in the next tutorial:
65 65
66 -- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/) 66 +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/)
67 67
68 <br> 68 <br>
69 <br> 69 <br>
ui-ngx/src/assets/help/en_US/rulenode/filter_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/filter_node_script_fn.md
@@ -61,8 +61,8 @@ return false; @@ -61,8 +61,8 @@ return false;
61 61
62 You can see real life example, how to use this node in those tutorials: 62 You can see real life example, how to use this node in those tutorials:
63 63
64 -- [Create and Clear Alarms{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/#node-a-filter-script)  
65 -- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-filter-script-node) 64 +- [Create and Clear Alarms{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/create-clear-alarms/#node-a-filter-script)
  65 +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-filter-script-node)
66 66
67 <br> 67 <br>
68 <br> 68 <br>
ui-ngx/src/assets/help/en_US/rulenode/generator_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/generator_node_script_fn.md
ui-ngx/src/assets/help/en_US/rulenode/log_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/log_node_script_fn.md
@@ -31,7 +31,7 @@ return 'Incoming message:\n' + JSON.stringify(msg) + @@ -31,7 +31,7 @@ return 'Incoming message:\n' + JSON.stringify(msg) +
31 31
32 You can see real life example, how to use this node in this tutorial: 32 You can see real life example, how to use this node in this tutorial:
33 33
34 -- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#log-unknown-request) 34 +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#log-unknown-request)
35 35
36 <br> 36 <br>
37 <br> 37 <br>
ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/switch_node_script_fn.md
@@ -90,7 +90,7 @@ return []; @@ -90,7 +90,7 @@ return [];
90 90
91 You can see real life example, how to use this node in this tutorial: 91 You can see real life example, how to use this node in this tutorial:
92 92
93 -- [Data function based on telemetry from 2 devices{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain) 93 +- [Data function based on telemetry from 2 devices{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/function-based-on-telemetry-from-two-devices#delta-temperature-rule-chain)
94 94
95 <br> 95 <br>
96 <br> 96 <br>
ui-ngx/src/assets/help/en_US/rulenode/transformation_node_script_fn.md renamed from rule-engine/rule-engine-components/src/main/resources/public/assets/help/en_US/rulenode/transformation_node_script_fn.md
@@ -52,8 +52,8 @@ return {msg: msg, metadata: metadata, msgType: newType}; @@ -52,8 +52,8 @@ return {msg: msg, metadata: metadata, msgType: newType};
52 52
53 You can see real life example, how to use this node in those tutorials: 53 You can see real life example, how to use this node in those tutorials:
54 54
55 -- [Transform incoming telemetry{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/)  
56 -- [Reply to RPC Calls{:target="_blank"}](${baseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node) 55 +- [Transform incoming telemetry{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/transform-incoming-telemetry/)
  56 +- [Reply to RPC Calls{:target="_blank"}](${siteBaseUrl}/docs/user-guide/rule-engine-2-0/tutorials/rpc-reply-tutorial#add-transform-script-node)
57 57
58 <br> 58 <br>
59 <br> 59 <br>
@@ -20,6 +20,7 @@ A JavaScript function performing custom action. @@ -20,6 +20,7 @@ A JavaScript function performing custom action.
20 * Display alert dialog with entity information: 20 * Display alert dialog with entity information:
21 21
22 ```javascript 22 ```javascript
  23 +{:code-style="max-height: 300px;"}
23 var title; 24 var title;
24 var content; 25 var content;
25 if (entityName) { 26 if (entityName) {
@@ -52,6 +53,7 @@ function showAlertDialog(title, content) { @@ -52,6 +53,7 @@ function showAlertDialog(title, content) {
52 * Delete device after confirmation: 53 * Delete device after confirmation:
53 54
54 ```javascript 55 ```javascript
  56 +{:code-style="max-height: 300px;"}
55 var $injector = widgetContext.$scope.$injector; 57 var $injector = widgetContext.$scope.$injector;
56 var dialogs = $injector.get(widgetContext.servicesMap.get('dialogs')); 58 var dialogs = $injector.get(widgetContext.servicesMap.get('dialogs'));
57 var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); 59 var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));
1 #### HTML template of dialog to create a device or an asset 1 #### HTML template of dialog to create a device or an asset
2 2
3 ```html 3 ```html
  4 +{:code-style="max-height: 400px;"}
4 <form #addEntityForm="ngForm" [formGroup]="addEntityFormGroup" 5 <form #addEntityForm="ngForm" [formGroup]="addEntityFormGroup"
5 (ngSubmit)="save()" class="add-entity-form"> 6 (ngSubmit)="save()" class="add-entity-form">
6 <mat-toolbar fxLayout="row" color="primary"> 7 <mat-toolbar fxLayout="row" color="primary">
@@ -158,3 +159,6 @@ @@ -158,3 +159,6 @@
158 </form> 159 </form>
159 {:copy-code} 160 {:copy-code}
160 ``` 161 ```
  162 +
  163 +<br>
  164 +<br>
1 #### Function displaying dialog to create a device or an asset 1 #### Function displaying dialog to create a device or an asset
2 2
3 ```javascript 3 ```javascript
  4 +{:code-style="max-height: 400px;"}
4 let $injector = widgetContext.$scope.$injector; 5 let $injector = widgetContext.$scope.$injector;
5 let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); 6 let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));
6 let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); 7 let assetService = $injector.get(widgetContext.servicesMap.get('assetService'));
@@ -130,3 +131,6 @@ function AddEntityDialogController(instance) { @@ -130,3 +131,6 @@ function AddEntityDialogController(instance) {
130 } 131 }
131 {:copy-code} 132 {:copy-code}
132 ``` 133 ```
  134 +
  135 +<br>
  136 +<br>
1 #### HTML template of dialog to edit a device or an asset 1 #### HTML template of dialog to edit a device or an asset
2 2
3 ```html 3 ```html
  4 +{:code-style="max-height: 400px;"}
4 <form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup" 5 <form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup"
5 (ngSubmit)="save()" class="edit-entity-form"> 6 (ngSubmit)="save()" class="edit-entity-form">
6 <mat-toolbar fxLayout="row" color="primary"> 7 <mat-toolbar fxLayout="row" color="primary">
@@ -190,3 +191,6 @@ @@ -190,3 +191,6 @@
190 </form> 191 </form>
191 {:copy-code} 192 {:copy-code}
192 ``` 193 ```
  194 +
  195 +<br>
  196 +<br>
1 #### Function displaying dialog to edit a device or an asset 1 #### Function displaying dialog to edit a device or an asset
2 2
3 ```javascript 3 ```javascript
  4 +{:code-style="max-height: 400px;"}
4 let $injector = widgetContext.$scope.$injector; 5 let $injector = widgetContext.$scope.$injector;
5 let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); 6 let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));
6 let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); 7 let entityService = $injector.get(widgetContext.servicesMap.get('entityService'));
@@ -218,3 +219,6 @@ function EditEntityDialogController(instance) { @@ -218,3 +219,6 @@ function EditEntityDialogController(instance) {
218 } 219 }
219 {:copy-code} 220 {:copy-code}
220 ``` 221 ```
  222 +
  223 +<br>
  224 +<br>
@@ -34,7 +34,7 @@ function showQrCodeDialog(title, code, format) { @@ -34,7 +34,7 @@ function showQrCodeDialog(title, code, format) {
34 {:copy-code} 34 {:copy-code}
35 ``` 35 ```
36 36
37 -* Parse code as a device claiming info (in this case ```{deviceName: string, secretKey: string}```)<br>and then claim device (see [Claiming devices{:target="_blank"}](${baseUrl}/docs/user-guide/claiming-devices/) for details): 37 +* Parse code as a device claiming info (in this case ```{deviceName: string, secretKey: string}```)<br>and then claim device (see [Claiming devices{:target="_blank"}](${siteBaseUrl}/docs/user-guide/claiming-devices/) for details):
38 38
39 ```javascript 39 ```javascript
40 var $scope = widgetContext.$scope; 40 var $scope = widgetContext.$scope;
@@ -127,7 +127,7 @@ self.onDataUpdated = function() { @@ -127,7 +127,7 @@ self.onDataUpdated = function() {
127 127
128 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 128 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
129 129
130 -![image](${baseUrl}/images/user-guide/contribution/widgets/alarm-widget-sample.png) 130 +![image](${helpBaseUrl}/help/images/widget/editor/examples/alarm-widget-sample.png)
131 131
132 In this example, the **alarmSource** and **alarms** properties of <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> are assigned to **$scope** and become accessible within HTML template. 132 In this example, the **alarmSource** and **alarms** properties of <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> are assigned to **$scope** and become accessible within HTML template.
133 133
@@ -57,7 +57,7 @@ self.onDataUpdated = function() { @@ -57,7 +57,7 @@ self.onDataUpdated = function() {
57 57
58 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 58 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
59 59
60 -![image](${baseUrl}/images/user-guide/contribution/widgets/external-js-widget-sample.png) 60 +![image](${helpBaseUrl}/help/images/widget/editor/examples/external-js-widget-sample.png)
61 61
62 In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section. 62 In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section.
63 63
@@ -98,7 +98,7 @@ self.onDataUpdated = function() { @@ -98,7 +98,7 @@ self.onDataUpdated = function() {
98 98
99 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 99 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
100 100
101 -![image](${baseUrl}/images/user-guide/contribution/widgets/external-js-timeseries-widget-sample.png) 101 +![image](${helpBaseUrl}/help/images/widget/editor/examples/external-js-timeseries-widget-sample.png)
102 102
103 In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section. 103 In this example, the external JS library API was used that becomes available after injecting the corresponding URL in **Resources** section.
104 104
@@ -37,7 +37,7 @@ The **Widget Editor** will open, pre-populated with the content of the default * @@ -37,7 +37,7 @@ The **Widget Editor** will open, pre-populated with the content of the default *
37 37
38 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 38 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
39 39
40 -![image](${baseUrl}/images/user-guide/contribution/widgets/latest-values-widget-sample.png) 40 +![image](${helpBaseUrl}/help/images/widget/editor/examples/latest-values-widget-sample.png)
41 41
42 In this example, the **data** property of <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> is assigned to the **$scope** and becomes accessible within the HTML template. 42 In this example, the **data** property of <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> is assigned to the **$scope** and becomes accessible within the HTML template.
43 43
@@ -114,7 +114,7 @@ self.onInit = function() { @@ -114,7 +114,7 @@ self.onInit = function() {
114 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 114 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
115 - Click dashboard edit button on the preview section to change the size of the resulting widget. Then click dashboard apply button. The final widget should look like the image below. 115 - Click dashboard edit button on the preview section to change the size of the resulting widget. Then click dashboard apply button. The final widget should look like the image below.
116 116
117 -![image](${baseUrl}/images/user-guide/contribution/widgets/control-widget-sample.png) 117 +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample.png)
118 118
119 - Click the **Save** button on the **Widget Editor Toolbar** to save widget type. 119 - Click the **Save** button on the **Widget Editor Toolbar** to save widget type.
120 120
@@ -123,13 +123,13 @@ To test how this widget performs RPC commands, we will need to place it in a das @@ -123,13 +123,13 @@ To test how this widget performs RPC commands, we will need to place it in a das
123 - Login as Tenant administrator. 123 - Login as Tenant administrator.
124 - Navigate to **Devices** and create new device with some name, for ex. "My RPC Device". 124 - Navigate to **Devices** and create new device with some name, for ex. "My RPC Device".
125 - Open device details and click "Copy Access Token" button to copy device access token to clipboard. 125 - Open device details and click "Copy Access Token" button to copy device access token to clipboard.
126 -- Download [mqtt-js-rpc-from-server.sh{:target="_blank"}](${baseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.sh) and [mqtt-js-rpc-from-server.js{:target="_blank"}](${baseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.js). Place these files in a folder. 126 +- Download [mqtt-js-rpc-from-server.sh{:target="_blank"}](${siteBaseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.sh) and [mqtt-js-rpc-from-server.js{:target="_blank"}](${siteBaseUrl}/docs/reference/resources/mqtt-js-rpc-from-server.js). Place these files in a folder.
127 Edit **mqtt-js-rpc-from-server.sh** - replace **$ACCESS_TOKEN** with your device access token from the clipboard. And install mqtt client library. 127 Edit **mqtt-js-rpc-from-server.sh** - replace **$ACCESS_TOKEN** with your device access token from the clipboard. And install mqtt client library.
128 - Run **mqtt-js-rpc-from-server.sh** script. You should see a "connected" message in the console. 128 - Run **mqtt-js-rpc-from-server.sh** script. You should see a "connected" message in the console.
129 - Navigate to **Dashboards** and create a new dashboard with some name, for ex. "My first control dashboard". Open this dashboard. 129 - Navigate to **Dashboards** and create a new dashboard with some name, for ex. "My first control dashboard". Open this dashboard.
130 - Click dashboard "edit" button. In the dashboard edit mode, click the "Entity aliases" button located on the dashboard toolbar. 130 - Click dashboard "edit" button. In the dashboard edit mode, click the "Entity aliases" button located on the dashboard toolbar.
131 131
132 -![image](${baseUrl}/images/user-guide/contribution/widgets/dashboard-toolbar-entity-aliases.png) 132 +![image](${helpBaseUrl}/help/images/widget/editor/examples/dashboard-toolbar-entity-aliases.png)
133 133
134 - Inside **Entity aliases** popup click "Add alias". 134 - Inside **Entity aliases** popup click "Add alias".
135 - Fill "Alias name" field, for ex. "My RPC Device Alias". 135 - Fill "Alias name" field, for ex. "My RPC Device Alias".
@@ -137,12 +137,12 @@ To test how this widget performs RPC commands, we will need to place it in a das @@ -137,12 +137,12 @@ To test how this widget performs RPC commands, we will need to place it in a das
137 - Choose "Device" in "Type" field. 137 - Choose "Device" in "Type" field.
138 - Select your device in "Entity list" field. In this example "My RPC Device". 138 - Select your device in "Entity list" field. In this example "My RPC Device".
139 139
140 -![image](${baseUrl}/images/user-guide/contribution/widgets/add-rpc-device-alias.png) 140 +![image](${helpBaseUrl}/help/images/widget/editor/examples/add-rpc-device-alias.png)
141 141
142 - Click "Add" and then "Save" in **Entity aliases**. 142 - Click "Add" and then "Save" in **Entity aliases**.
143 - Click dashboard "+" button then click "Create new widget" button. 143 - Click dashboard "+" button then click "Create new widget" button.
144 144
145 -![image](${baseUrl}/images/user-guide/contribution/widgets/dashboard-create-new-widget-button.png) 145 +![image](${helpBaseUrl}/help/images/widget/editor/examples/dashboard-create-new-widget-button.png)
146 146
147 - Then select **Widget Bundle** where your RPC widget was saved. Select "Control widget" tab. 147 - Then select **Widget Bundle** where your RPC widget was saved. Select "Control widget" tab.
148 - Click your widget. In this example, "My first control widget". 148 - Click your widget. In this example, "My first control widget".
@@ -152,7 +152,7 @@ To test how this widget performs RPC commands, we will need to place it in a das @@ -152,7 +152,7 @@ To test how this widget performs RPC commands, we will need to place it in a das
152 - Fill **RPC params** field with RPC params. For ex. "{ param1: "value1" }". 152 - Fill **RPC params** field with RPC params. For ex. "{ param1: "value1" }".
153 - Click **Send RPC command** button. You should see the following response in the widget. 153 - Click **Send RPC command** button. You should see the following response in the widget.
154 154
155 -![image](${baseUrl}/images/user-guide/contribution/widgets/control-widget-sample-response-one-way.png) 155 +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-one-way.png)
156 156
157 The following output should be printed in the device console: 157 The following output should be printed in the device console:
158 158
@@ -166,18 +166,18 @@ In order to test "Two way" RPC command mode, we need to change the corresponding @@ -166,18 +166,18 @@ In order to test "Two way" RPC command mode, we need to change the corresponding
166 - Click dashboard "edit" button. In dashboard edit mode, click **Edit widget** button located in the header of Control widget. 166 - Click dashboard "edit" button. In dashboard edit mode, click **Edit widget** button located in the header of Control widget.
167 - In the widget details, view select "Advanced" tab and uncheck "Is One Way Command" checkbox. 167 - In the widget details, view select "Advanced" tab and uncheck "Is One Way Command" checkbox.
168 168
169 -![image](${baseUrl}/images/user-guide/contribution/widgets/control-widget-sample-settings.png) 169 +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-settings.png)
170 170
171 - Click **Apply changes** button on the widget details header. Close details and click dashboard **Apply changes** button. 171 - Click **Apply changes** button on the widget details header. Close details and click dashboard **Apply changes** button.
172 - Fill widget fields with RPC method name and params like in previous steps. 172 - Fill widget fields with RPC method name and params like in previous steps.
173 Click **Send RPC command** button. You should see the following response in the widget. 173 Click **Send RPC command** button. You should see the following response in the widget.
174 174
175 -![image](${baseUrl}/images/user-guide/contribution/widgets/control-widget-sample-response-two-way.png) 175 +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-two-way.png)
176 176
177 - stop **mqtt-js-rpc-from-server.sh** script. 177 - stop **mqtt-js-rpc-from-server.sh** script.
178 Click **Send RPC command** button. You should see the following response in the widget. 178 Click **Send RPC command** button. You should see the following response in the widget.
179 179
180 -![image](${baseUrl}/images/user-guide/contribution/widgets/control-widget-sample-response-timeout.png) 180 +![image](${helpBaseUrl}/help/images/widget/editor/examples/control-widget-sample-response-timeout.png)
181 181
182 In this example, **controlApi** is used to send RPC commands. Additionally, custom widget settings were introduced in order to configure RPC command mode and RPC request timeout. 182 In this example, **controlApi** is used to send RPC commands. Additionally, custom widget settings were introduced in order to configure RPC command mode and RPC request timeout.
183 183
@@ -58,7 +58,7 @@ self.onInit = function() { @@ -58,7 +58,7 @@ self.onInit = function() {
58 58
59 - Click the **Run** button on the **Widget Editor Toolbar** to see the resulting **Widget preview** section. 59 - Click the **Run** button on the **Widget Editor Toolbar** to see the resulting **Widget preview** section.
60 60
61 -![image](${baseUrl}/images/user-guide/contribution/widgets/static-widget-sample.png) 61 +![image](${helpBaseUrl}/help/images/widget/editor/examples/static-widget-sample.png)
62 62
63 This is just a static HTML widget. There is no subscription data and no special widget API was used. 63 This is just a static HTML widget. There is no subscription data and no special widget API was used.
64 64
@@ -75,7 +75,7 @@ self.onDataUpdated = function() { @@ -75,7 +75,7 @@ self.onDataUpdated = function() {
75 75
76 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section. 76 - Click the **Run** button on the **Widget Editor Toolbar** in order to see the result in **Widget preview** section.
77 77
78 -![image](${baseUrl}/images/user-guide/contribution/widgets/timeseries-widget-sample.png) 78 +![image](${helpBaseUrl}/help/images/widget/editor/examples/timeseries-widget-sample.png)
79 79
80 In this example, the <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> **datasources** and **data** properties are assigned to **$scope** and become accessible within the HTML template. 80 In this example, the <span trigger-style="fontSize: 16px;" trigger-text="<b>subscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> **datasources** and **data** properties are assigned to **$scope** and become accessible within the HTML template.
81 81
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 <div class="divider"></div> 3 <div class="divider"></div>
4 <br/> 4 <br/>
5 5
6 -All widget related JavaScript code according to the [Widget API{:target="_blank"}](${baseUrl}/docs/user-guide/contribution/widgets-development/#basic-widget-api). 6 +All widget related JavaScript code according to the [Widget API{:target="_blank"}](${siteBaseUrl}/docs/user-guide/contribution/widgets-development/#basic-widget-api).
7 The built-in variable **self** is a reference to the widget instance.<br> 7 The built-in variable **self** is a reference to the widget instance.<br>
8 Each widget function should be defined as a property of the **self** variable. 8 Each widget function should be defined as a property of the **self** variable.
9 **self** variable has property **ctx** of type [WidgetContext{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107) - a reference to widget context that has all necessary API and data used by widget instance. 9 **self** variable has property **ctx** of type [WidgetContext{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107) - a reference to widget context that has all necessary API and data used by widget instance.
@@ -129,7 +129,7 @@ Browser debugger (if enabled) will automatically pause code execution at the deb @@ -129,7 +129,7 @@ Browser debugger (if enabled) will automatically pause code execution at the deb
129 129
130 ##### Further reading 130 ##### Further reading
131 131
132 -For more information read [Widgets Development Guide{:target="_blank"}](${baseUrl}/docs/user-guide/contribution/widgets-development). 132 +For more information read [Widgets Development Guide{:target="_blank"}](${siteBaseUrl}/docs/user-guide/contribution/widgets-development).
133 133
134 <br> 134 <br>
135 <br> 135 <br>
@@ -3,10 +3,10 @@ @@ -3,10 +3,10 @@
3 <div class="divider"></div> 3 <div class="divider"></div>
4 <br/> 4 <br/>
5 5
6 -The widget subscription object is instance of [IWidgetSubscription{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/core/api/widget-api.models.ts#L264") and contains all subscription information, including current data, according to the [widget type{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#widget-types). 6 +The widget subscription object is instance of [IWidgetSubscription{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/core/api/widget-api.models.ts#L264") and contains all subscription information, including current data, according to the [widget type{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#widget-types).
7 7
8 Depending on widget type, subscription object provides different data structures. 8 Depending on widget type, subscription object provides different data structures.
9 -For [Latest values{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#latest-values) and [Time-series{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#time-series) widget types, it provides the following properties: 9 +For [Latest values{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#latest-values) and [Time-series{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#time-series) widget types, it provides the following properties:
10 10
11 - **datasources** - array of datasources (Array<[Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)>) used by this subscription, using the following structure: 11 - **datasources** - array of datasources (Array<[Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)>) used by this subscription, using the following structure:
12 12
@@ -54,7 +54,7 @@ For [Latest values{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-libra @@ -54,7 +54,7 @@ For [Latest values{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-libra
54 ] 54 ]
55 ``` 55 ```
56 56
57 -For [Alarm widget{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#alarm-widget) type it provides the following properties: 57 +For [Alarm widget{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#alarm-widget) type it provides the following properties:
58 58
59 - **alarmSource** - ([Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)) information about entity for which alarms are fetched, using the following structure: 59 - **alarmSource** - ([Datasource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L279)) information about entity for which alarms are fetched, using the following structure:
60 60
@@ -110,4 +110,4 @@ For [Alarm widget{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-librar @@ -110,4 +110,4 @@ For [Alarm widget{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-librar
110 ] 110 ]
111 ``` 111 ```
112 112
113 -For [RPC{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#rpc-control-widget) or [Static{:target="_blank"}](${baseUrl}/docs/user-guide/ui/widget-library/#static) widget types, subscription object is optional and does not contain necessary information. 113 +For [RPC{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#rpc-control-widget) or [Static{:target="_blank"}](${siteBaseUrl}/docs/user-guide/ui/widget-library/#static) widget types, subscription object is optional and does not contain necessary information.
@@ -33,7 +33,7 @@ return '# Some title\n - Entity name: ' + data[0]['entityName']; @@ -33,7 +33,7 @@ return '# Some title\n - Entity name: ' + data[0]['entityName'];
33 <ul> 33 <ul>
34 <li> 34 <li>
35 Display greetings for currently logged-in user.<br> 35 Display greetings for currently logged-in user.<br>
36 -Let's assume widget has first datasource configured using <code>Current User</code> <a target="_blank" href="${baseUrl}/docs/user-guide/ui/aliases/#single-entity">Single entity</a> alias<br> 36 +Let's assume widget has first datasource configured using <code>Current User</code> <a target="_blank" href="${siteBaseUrl}/docs/user-guide/ui/aliases/#single-entity">Single entity</a> alias<br>
37 and has data keys for <code>firstName</code>, <code>lastName</code> and <code>name</code> entity fields: 37 and has data keys for <code>firstName</code>, <code>lastName</code> and <code>name</code> entity fields:
38 </li> 38 </li>
39 </ul> 39 </ul>
@@ -34,7 +34,7 @@ return data[0] ? data[0]['entityName'] : ''; @@ -34,7 +34,7 @@ return data[0] ? data[0]['entityName'] : '';
34 <li> 34 <li>
35 Prepare QR code text to use as device claiming info (in this case <code>{deviceName: string, secretKey: string}</code>).<br> 35 Prepare QR code text to use as device claiming info (in this case <code>{deviceName: string, secretKey: string}</code>).<br>
36 Let's assume device has <code>claimingData</code> attribute with string JSON value containing <code>secretKey</code> field<br> 36 Let's assume device has <code>claimingData</code> attribute with string JSON value containing <code>secretKey</code> field<br>
37 -(see <a target="_blank" href="${baseUrl}/docs/user-guide/claiming-devices/">Claiming devices</a>): 37 +(see <a target="_blank" href="${siteBaseUrl}/docs/user-guide/claiming-devices/">Claiming devices</a>):
38 </li> 38 </li>
39 </ul> 39 </ul>
40 40