diff --git a/ui-ngx/src/app/core/http/dashboard.service.ts b/ui-ngx/src/app/core/http/dashboard.service.ts index dda8b144e5..4aa72f74d5 100644 --- a/ui-ngx/src/app/core/http/dashboard.service.ts +++ b/ui-ngx/src/app/core/http/dashboard.service.ts @@ -71,8 +71,12 @@ export class DashboardService { return this.http.get(`/api/dashboard/${dashboardId}`, defaultHttpOptionsFromConfig(config)); } - public exportDashboard(dashboardId: string, config?: RequestConfig): Observable { - return this.http.get(`/api/dashboard/${dashboardId}?includeResources=true`, defaultHttpOptionsFromConfig(config)); + public exportDashboard(dashboardId: string, includeResources = true, config?: RequestConfig): Observable { + let url = `/api/dashboard/${dashboardId}`; + if (includeResources) { + url += '?includeResources=true'; + } + return this.http.get(url, defaultHttpOptionsFromConfig(config)); } public getDashboardInfo(dashboardId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 3549e6ebd2..78f025e59d 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -20,7 +20,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { forkJoin, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; -import { Resource, ResourceInfo, ResourceType } from '@shared/models/resource.models'; +import { Resource, ResourceInfo, ResourceType, TBResourceScope } from '@shared/models/resource.models'; import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; @@ -52,10 +52,14 @@ export class ResourceService { return this.http.get(`/api/resource/${resourceId}`, defaultHttpOptionsFromConfig(config)); } - public getResourceInfo(resourceId: string, config?: RequestConfig): Observable { + public getResourceInfoById(resourceId: string, config?: RequestConfig): Observable { return this.http.get(`/api/resource/info/${resourceId}`, defaultHttpOptionsFromConfig(config)); } + public getResourceInfo(type: ResourceType, scope: TBResourceScope, key: string, config?: RequestConfig): Observable { + return this.http.get(`/api/resource/${type}/${scope}/${key}/info`, defaultHttpOptionsFromConfig(config)); + } + public downloadResource(resourceId: string): Observable { return this.resourcesService.downloadResource(`/api/resource/${resourceId}/download`); } diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index 293a7ff98c..cf335ae479 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -31,7 +31,6 @@ import { WidgetTypeInfo, widgetTypesData } from '@shared/models/widget.models'; -import { TranslateService } from '@ngx-translate/core'; import { toWidgetInfo, toWidgetTypeDetails, WidgetInfo } from '@app/modules/home/models/widget-component.models'; import { filter, map, mergeMap, tap } from 'rxjs/operators'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -53,7 +52,6 @@ export class WidgetService { constructor( private http: HttpClient, - private translate: TranslateService, private router: Router ) { this.router.events.pipe(filter(event => event instanceof ActivationEnd)).subscribe( @@ -143,9 +141,13 @@ export class WidgetService { } public exportBundleWidgetTypesDetails(widgetsBundleId: string, - config?: RequestConfig): Observable> { - return this.http.get>(`/api/widgetTypesDetails?widgetsBundleId=${widgetsBundleId}&includeResources=true`, - defaultHttpOptionsFromConfig(config)); + includeResources = true, + config?: RequestConfig): Observable> { + let url = `/api/widgetTypesDetails?widgetsBundleId=${widgetsBundleId}` + if (includeResources) { + url += '&includeResources=true'; + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public getBundleWidgetTypeFqns(widgetsBundleId: string, @@ -211,9 +213,13 @@ export class WidgetService { } public exportWidgetType(widgetTypeId: string, + includeResources = true, config?: RequestConfig): Observable { - return this.http.get(`/api/widgetType/${widgetTypeId}?includeResources=true`, - defaultHttpOptionsFromConfig(config)); + let url = `/api/widgetType/${widgetTypeId}`; + if (includeResources) { + url += '?includeResources=true'; + } + return this.http.get(url, defaultHttpOptionsFromConfig(config)); } public getWidgetTypeInfoById(widgetTypeId: string, diff --git a/ui-ngx/src/app/core/services/resources.service.ts b/ui-ngx/src/app/core/services/resources.service.ts index 21bd11293e..f5cba07a47 100644 --- a/ui-ngx/src/app/core/services/resources.service.ts +++ b/ui-ngx/src/app/core/services/resources.service.ts @@ -39,6 +39,7 @@ import { AppState } from '@core/core.state'; import { map, tap } from 'rxjs/operators'; import { RequestConfig } from '@core/http/http-utils'; import { getFlexLayoutModule } from '@app/shared/legacy/flex-layout.models'; +import { isJSResource, removeTbResourcePrefix } from '@shared/models/resource.models'; export interface ModuleInfo { module: ɵNgModuleDef; @@ -377,11 +378,11 @@ export class ResourcesService { if (isObject(resourceId)) { return `/api/resource/js/${(resourceId as TbResourceId).id}/download`; } - return resourceId as string; + return removeTbResourcePrefix(resourceId as string); } private getMetaInfo(resourceId: string | TbResourceId): object { - if (isObject(resourceId)) { + if (isObject(resourceId) || (typeof resourceId === 'string' && isJSResource(resourceId))) { return { additionalHeaders: { 'X-Authorization': `Bearer ${AuthService.getJwtToken()}` diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 52995f3f9a..a9f2dc4cec 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -338,6 +338,7 @@ import { TimezonePanelComponent } from '@shared/components/time/timezone-panel.c import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component'; import { Observable, map, of } from 'rxjs'; import { getFlexLayout } from '@shared/legacy/flex-layout.models'; +import { isJSResourceUrl } from '@shared/public-api'; class ModulesMap implements IModulesMap { @@ -693,7 +694,7 @@ class ModulesMap implements IModulesMap { for (const moduleId of Object.keys(this.modulesMap)) { System.set('app:' + moduleId, this.modulesMap[moduleId]); } - System.constructor.prototype.shouldFetch = (url: string) => url.endsWith('/download'); + System.constructor.prototype.shouldFetch = (url: string) => url.endsWith('/download') || isJSResourceUrl(url); System.constructor.prototype.fetch = (url: string, options: RequestInit & {meta?: any}) => { if (options?.meta?.additionalHeaders) { options.headers = { ...options.headers, ...options.meta.additionalHeaders }; diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index 82a599b0a8..8385fd265c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -84,7 +84,7 @@ export class ResourcesLibraryTableConfigResolver { this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType); - this.config.loadEntity = id => this.resourceService.getResourceInfo(id.id); + this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id); this.config.saveEntity = resource => this.saveResource(resource); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); diff --git a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts index d1f91810f0..2524cb012a 100644 --- a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts @@ -20,7 +20,14 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { Observable, of } from 'rxjs'; import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/operators'; import { isDefinedAndNotNull, isEmptyStr, isEqual, isObject } from '@core/utils'; -import { ResourceInfo, ResourceType } from '@shared/models/resource.models'; +import { + extractParamsFromJSResourceUrl, + isJSResource, + prependTbResourcePrefix, + removeTbResourcePrefix, + ResourceInfo, + ResourceType +} from '@shared/models/resource.models'; import { TbResourceId } from '@shared/models/id/tb-resource-id'; import { ResourceService } from '@core/http/resource.service'; import { PageLink } from '@shared/models/page/page-link'; @@ -64,7 +71,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn allowAutocomplete = false; resourceFormGroup = this.fb.group({ - resource: [null] + resource: this.fb.control(null) }); filteredResources$: Observable>; @@ -73,10 +80,10 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn @ViewChild('resourceInput', {static: true}) resourceInput: ElementRef; - private modelValue: string | TbResourceId; + private modelValue: string; private dirty = false; - private propagateChange = (v: any) => { }; + private propagateChange: (value: any) => void = () => {}; constructor(private fb: FormBuilder, private resourceService: ResourceService) { @@ -91,13 +98,13 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn .pipe( debounceTime(150), tap(value => { - let modelValue; + let modelValue: string; if (isObject(value)) { - modelValue = value.id; + modelValue = prependTbResourcePrefix((value as ResourceInfo).link); } else if (isEmptyStr(value)) { modelValue = null; } else { - modelValue = value; + modelValue = value as string; } this.updateView(modelValue); if (value === null) { @@ -105,7 +112,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn } }), map(value => value ? (typeof value === 'string' ? value : value.title) : ''), - switchMap(name => this.fetchResources(name) ), + switchMap(name => this.fetchResources(name)), share() ); } @@ -114,7 +121,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } setDisabledState(isDisabled: boolean) { @@ -130,9 +137,9 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn if (isDefinedAndNotNull(value)) { this.searchText = ''; if (isObject(value) && typeof value !== 'string' && (value as TbResourceId).id) { - this.resourceService.getResourceInfo(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe({ + this.resourceService.getResourceInfoById(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe({ next: resource => { - this.modelValue = resource.id; + this.modelValue = prependTbResourcePrefix(resource.link); this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false}); }, error: () => { @@ -140,9 +147,22 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn this.resourceFormGroup.get('resource').patchValue(''); } }); + } else if (typeof value === 'string' && isJSResource(value)) { + const url = removeTbResourcePrefix(value); + const params = extractParamsFromJSResourceUrl(url); + this.resourceService.getResourceInfo(params.type, params.scope, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe({ + next: resource => { + this.modelValue = value; + this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false}); + }, + error: () => { + this.modelValue = ''; + this.resourceFormGroup.get('resource').patchValue(''); + } + }) } else { - this.modelValue = value; - this.resourceFormGroup.get('resource').patchValue(value, {emitEvent: false}); + this.modelValue = value as string; + this.resourceFormGroup.get('resource').patchValue(value as string, {emitEvent: false}); } this.dirty = true; } @@ -167,7 +187,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn } } - private updateView(value: string | TbResourceId ) { + private updateView(value: string) { if (!isEqual(this.modelValue, value)) { this.modelValue = value; this.propagateChange(this.modelValue); diff --git a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html new file mode 100644 index 0000000000..e7dccbaea9 --- /dev/null +++ b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html @@ -0,0 +1,46 @@ + + +

{{ title | translate }}

+ + +
+ + +
+
+ {{ prompt | translate }} +
+
+
+ + +
diff --git a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts new file mode 100644 index 0000000000..b9b0046257 --- /dev/null +++ b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts @@ -0,0 +1,73 @@ +/// +/// Copyright © 2016-2024 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 } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { isDefinedAndNotNull } from '@core/utils'; + +export interface ExportResourceDialogData { + title: string; + prompt: string; + include?: boolean; + ignoreLoading?: boolean; +} + +export interface ExportResourceDialogDialogResult { + include: boolean; +} + +@Component({ + selector: 'tb-export-resource-dialog', + templateUrl: './export-resource-dialog.component.html', + styleUrls: [] +}) +export class ExportResourceDialogComponent extends DialogComponent { + + ignoreLoading = false; + + title: string; + prompt: string + + includeResourcesFormControl = new FormControl(true); + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) private data: ExportResourceDialogData, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + this.ignoreLoading = this.data.ignoreLoading; + this.title = this.data.title; + this.prompt = this.data.prompt; + if (isDefinedAndNotNull(this.data.include)) { + this.includeResourcesFormControl.patchValue(this.data.include, {emitEvent: false}); + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + export(): void { + this.dialogRef.close({ + include: this.includeResourcesFormControl.value + }); + } +} diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 8db1ea95eb..d61428b438 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -87,10 +87,17 @@ import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions'; import { ExportableEntity } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { Customer } from '@shared/models/customer.model'; +import { + ExportResourceDialogComponent, + ExportResourceDialogData, + ExportResourceDialogDialogResult +} from '@shared/import-export/export-resource-dialog.component'; export type editMissingAliasesFunction = (widgets: Array, isSingleWidget: boolean, customTitle: string, missingEntityAliases: EntityAliases) => Observable; +type SupportEntityResources = 'includeResourcesInExportWidgetTypes' | 'includeResourcesInExportDashboard'; + // @dynamic @Injectable() export class ImportExportService { @@ -148,16 +155,23 @@ export class ImportExportService { } public exportDashboard(dashboardId: string) { - this.dashboardService.exportDashboard(dashboardId).subscribe({ - next: (dashboard) => { - let name = dashboard.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareDashboardExport(dashboard), name); - }, - error: (e) => { - this.handleExportError(e, 'dashboard.export-failed-error'); - } - }); + this.getIncludeResourcesPreference('includeResourcesInExportDashboard').subscribe(includeResources => { + this.openExportDialog('dashboard.export', 'dashboard.export-prompt', includeResources).subscribe(result => { + if (result) { + this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportDashboard'); + this.dashboardService.exportDashboard(dashboardId, result.include).subscribe({ + next: (dashboard) => { + let name = dashboard.title; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareDashboardExport(dashboard), name); + }, + error: (e) => { + this.handleExportError(e, 'dashboard.export-failed-error'); + } + }); + } + }) + }) } public importDashboard(onEditMissingAliases: editMissingAliasesFunction): Observable { @@ -301,36 +315,54 @@ export class ImportExportService { } public exportWidgetType(widgetTypeId: string) { - this.widgetService.exportWidgetType(widgetTypeId).subscribe({ - next: (widgetTypeDetails) => { - let name = widgetTypeDetails.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetTypeDetails), name); - }, - error: (e) => { - this.handleExportError(e, 'widget-type.export-failed-error'); - } + this.getIncludeResourcesPreference('includeResourcesInExportWidgetTypes').subscribe(includeResources => { + this.openExportDialog('widget.export', 'widget.export-prompt', includeResources).subscribe(result => { + if (result) { + this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportWidgetTypes'); + this.widgetService.exportWidgetType(widgetTypeId, result.include).subscribe({ + next: (widgetTypeDetails) => { + let name = widgetTypeDetails.name; + name = name.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareExport(widgetTypeDetails), name); + }, + error: (e) => { + this.handleExportError(e, 'widget-type.export-failed-error'); + } + }); + } + }) }); } public exportWidgetTypes(widgetTypeIds: string[]): Observable { - const widgetTypesObservables: Array> = []; - for (const id of widgetTypeIds) { - widgetTypesObservables.push(this.widgetService.exportWidgetType(id)); - } - return forkJoin(widgetTypesObservables).pipe( - map((widgetTypes) => - Object.fromEntries(widgetTypes.map(wt=> { - let name = wt.name; - name = name.toLowerCase().replace(/\W/g, '_') + `.${JSON_TYPE.extension}`; - const data = JSON.stringify(this.prepareExport(wt), null, 2); - return [name, data]; - }))), - mergeMap(widgetTypeFiles => this.exportJSZip(widgetTypeFiles, 'widget_types')), - catchError(e => { - this.handleExportError(e, 'widget-type.export-failed-error'); - throw e; - }) + return this.getIncludeResourcesPreference('includeResourcesInExportWidgetTypes').pipe( + mergeMap(includeResources => + this.openExportDialog('widget.export-widgets', 'widget.export-widgets-prompt', includeResources).pipe( + mergeMap(result => { + if (result) { + this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportWidgetTypes'); + const widgetTypesObservables: Array> = []; + for (const id of widgetTypeIds) { + widgetTypesObservables.push(this.widgetService.exportWidgetType(id, result.include)); + } + return forkJoin(widgetTypesObservables).pipe( + map((widgetTypes) => + Object.fromEntries(widgetTypes.map(wt => { + let name = wt.name; + name = name.toLowerCase().replace(/\W/g, '_') + `.${JSON_TYPE.extension}`; + const data = JSON.stringify(this.prepareExport(wt), null, 2); + return [name, data]; + }))), + mergeMap(widgetTypeFiles => this.exportJSZip(widgetTypeFiles, 'widget_types')), + catchError(e => { + this.handleExportError(e, 'widget-type.export-failed-error'); + throw e; + }) + ); + } + }) + ) + ) ); } @@ -1187,4 +1219,27 @@ export class ImportExportService { return importedData; } + private getIncludeResourcesPreference(key: SupportEntityResources): Observable { + return this.store.pipe( + select(selectUserSettingsProperty(key)), + take(1) + ); + } + + private openExportDialog(title: string, prompt: string, includeResources: boolean) { + return this.dialog.open( + ExportResourceDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { title, prompt, include: includeResources } + } + ).afterClosed(); + } + + private updateUserSettingsIncludeResourcesIfNeeded(currentValue: boolean, newValue: boolean, key: SupportEntityResources) { + if (currentValue !== newValue) { + this.store.dispatch(new ActionPreferencesPutUserSettings({[key]: newValue })); + } + } + } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 688493d22f..9f6d605752 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -68,6 +68,8 @@ export interface TbResourceInfo extends Omit, 'name' | fileName: string; public: boolean; publicResourceKey?: string; + readonly link?: string; + readonly publicLink?: string; descriptor?: D; } @@ -87,10 +89,7 @@ export interface ImageDescriptor { previewDescriptor: ImageDescriptor; } -export interface ImageResourceInfo extends TbResourceInfo { - link?: string; - publicLink?: string; -} +export type ImageResourceInfo = TbResourceInfo; export interface ImageResource extends ImageResourceInfo { base64?: string; @@ -108,6 +107,7 @@ export interface ImageExportData { } export type ImageResourceType = 'tenant' | 'system'; +export type TBResourceScope = 'tenant' | 'system'; export type ImageReferences = {[entityType: string]: Array & HasTenantId>}; @@ -141,15 +141,19 @@ export const imageResourceType = (imageInfo: ImageResourceInfo): ImageResourceTy (!imageInfo.tenantId || imageInfo.tenantId?.id === NULL_UUID) ? 'system' : 'tenant'; export const TB_IMAGE_PREFIX = 'tb-image;'; +export const TB_RESOURCE_PREFIX = 'tb-resource;'; export const IMAGES_URL_REGEXP = /\/api\/images\/(tenant|system)\/(.*)/; export const IMAGES_URL_PREFIX = '/api/images'; +export const RESOURCES_URL_REGEXP = /\/api\/resource\/(js_module)\/(tenant|system)\/(.*)/; + export const PUBLIC_IMAGES_URL_PREFIX = '/api/images/public'; export const IMAGE_BASE64_URL_PREFIX = 'data:image/'; export const removeTbImagePrefix = (url: string): string => url ? url.replace(TB_IMAGE_PREFIX, '') : url; +export const removeTbResourcePrefix = (url: string): string => url ? url.replace(TB_RESOURCE_PREFIX, '') : url; export const removeTbImagePrefixFromUrls = (urls: string[]): string[] => urls ? urls.map(url => removeTbImagePrefix(url)) : []; @@ -162,9 +166,18 @@ export const prependTbImagePrefix = (url: string): string => { export const prependTbImagePrefixToUrls = (urls: string[]): string[] => urls ? urls.map(url => prependTbImagePrefix(url)) : []; +export const prependTbResourcePrefix = (url: string): string => { + if (url && !url.startsWith(TB_RESOURCE_PREFIX)) { + url = TB_RESOURCE_PREFIX + url; + } + return url; +}; export const isImageResourceUrl = (url: string): boolean => url && IMAGES_URL_REGEXP.test(url); +export const isJSResourceUrl = (url: string): boolean => url && RESOURCES_URL_REGEXP.test(url); +export const isJSResource = (url: string): boolean => url?.startsWith(TB_RESOURCE_PREFIX); + export const extractParamsFromImageResourceUrl = (url: string): {type: ImageResourceType; key: string} => { const res = url.match(IMAGES_URL_REGEXP); if (res?.length > 2) { @@ -174,6 +187,15 @@ export const extractParamsFromImageResourceUrl = (url: string): {type: ImageReso } }; +export const extractParamsFromJSResourceUrl = (url: string): {type: ResourceType; scope: TBResourceScope; key: string} => { + const res = url.match(RESOURCES_URL_REGEXP); + if (res?.length > 3) { + return {type: (res[1]).toUpperCase() as ResourceType, scope: res[2] as TBResourceScope, key: res[3]}; + } else { + return null; + } +}; + export const isBase64DataImageUrl = (url: string): boolean => url && url.startsWith(IMAGE_BASE64_URL_PREFIX); export const NO_IMAGE_DATA_URI = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; diff --git a/ui-ngx/src/app/shared/models/user-settings.models.ts b/ui-ngx/src/app/shared/models/user-settings.models.ts index bd2b675c98..8009535ed1 100644 --- a/ui-ngx/src/app/shared/models/user-settings.models.ts +++ b/ui-ngx/src/app/shared/models/user-settings.models.ts @@ -19,6 +19,8 @@ export interface UserSettings { notDisplayConnectivityAfterAddDevice?: boolean; notDisplayInstructionsAfterAddEdge?: boolean; includeBundleWidgetsInExport?: boolean; + includeResourcesInExportWidgetTypes?: boolean; + includeResourcesInExportDashboard?: boolean; } export const initialUserSettings: UserSettings = { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 8bb22b83df..010d449911 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -195,6 +195,7 @@ import { ImportExportService } from '@shared/import-export/import-export.service import { ImportDialogComponent } from '@shared/import-export/import-dialog.component'; import { ImportDialogCsvComponent } from '@shared/import-export/import-dialog-csv.component'; import { ExportWidgetsBundleDialogComponent } from '@shared/import-export/export-widgets-bundle-dialog.component'; +import { ExportResourceDialogComponent } from '@shared/import-export/export-resource-dialog.component'; import { TableColumnsAssignmentComponent } from '@shared/import-export/table-columns-assignment.component'; import { ScrollGridComponent } from '@shared/components/grid/scroll-grid.component'; import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component'; @@ -407,6 +408,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ImportDialogComponent, ImportDialogCsvComponent, ExportWidgetsBundleDialogComponent, + ExportResourceDialogComponent, TableColumnsAssignmentComponent, ScrollGridComponent, ImageGalleryComponent, @@ -664,6 +666,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ImportDialogComponent, ImportDialogCsvComponent, ExportWidgetsBundleDialogComponent, + ExportResourceDialogComponent, TableColumnsAssignmentComponent, ScrollGridComponent, ImageGalleryComponent, 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 0fa19cc176..5eef9756bf 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1262,6 +1262,7 @@ "import": "Import dashboard", "export": "Export dashboard", "export-failed-error": "Unable to export dashboard: {{error}}", + "export-prompt": "Include dashboard resources in exported data", "create-new-dashboard": "Create new dashboard", "dashboard-file": "Dashboard file", "invalid-dashboard-file-error": "Unable to import dashboard: Invalid dashboard data structure.", @@ -5217,7 +5218,9 @@ "selected-widgets": "{ count, plural, =1 {1 widget} other {# widgets} } selected", "undo": "Undo widget changes", "export": "Export widget", + "export-prompt": "Include widget resources in exported data", "export-widgets": "Export widgets", + "export-widgets-prompt": "Include widgets resources in exported data", "import": "Import widget", "no-data": "No data to display on widget", "data-overflow": "Widget displays {{count}} out of {{total}} entities",