Commit 10ddb5f8f37581611df15cb154127ce1f4d683bd
1 parent
67dd34db
Fix timeseries widget (invoke data updated callback from data aggregator on init…
…ial data). Improve widget selector.
Showing
8 changed files
with
123 additions
and
64 deletions
... | ... | @@ -155,6 +155,7 @@ export class DataAggregator { |
155 | 155 | } |
156 | 156 | |
157 | 157 | public onData(data: SubscriptionDataHolder, update: boolean, history: boolean, detectChanges: boolean) { |
158 | + this.updatedData = true; | |
158 | 159 | if (!this.dataReceived || this.resetPending) { |
159 | 160 | let updateIntervalScheduledTime = true; |
160 | 161 | if (!this.dataReceived) { |
... | ... | @@ -183,7 +184,6 @@ export class DataAggregator { |
183 | 184 | this.onInterval(history, detectChanges); |
184 | 185 | } |
185 | 186 | } |
186 | - this.updatedData = true; | |
187 | 187 | } |
188 | 188 | |
189 | 189 | private onInterval(history?: boolean, detectChanges?: boolean) { | ... | ... |
... | ... | @@ -55,6 +55,8 @@ export class WidgetService { |
55 | 55 | private systemWidgetsBundles: Array<WidgetsBundle>; |
56 | 56 | private tenantWidgetsBundles: Array<WidgetsBundle>; |
57 | 57 | |
58 | + private widgetTypeInfosCache = new Map<string, Array<WidgetTypeInfo>>(); | |
59 | + | |
58 | 60 | private loadWidgetsBundleCacheSubject: ReplaySubject<any>; |
59 | 61 | |
60 | 62 | constructor( |
... | ... | @@ -137,8 +139,15 @@ export class WidgetService { |
137 | 139 | |
138 | 140 | public getBundleWidgetTypeInfos(bundleAlias: string, isSystem: boolean, |
139 | 141 | config?: RequestConfig): Observable<Array<WidgetTypeInfo>> { |
140 | - return this.http.get<Array<WidgetTypeInfo>>(`/api/widgetTypesInfos?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, | |
141 | - defaultHttpOptionsFromConfig(config)); | |
142 | + const key = bundleAlias + (isSystem ? '_sys' : ''); | |
143 | + if (this.widgetTypeInfosCache.has(key)) { | |
144 | + return of(this.widgetTypeInfosCache.get(key)); | |
145 | + } else { | |
146 | + return this.http.get<Array<WidgetTypeInfo>>(`/api/widgetTypesInfos?isSystem=${isSystem}&bundleAlias=${bundleAlias}`, | |
147 | + defaultHttpOptionsFromConfig(config)).pipe( | |
148 | + tap((res) => this.widgetTypeInfosCache.set(key, res) ) | |
149 | + ); | |
150 | + } | |
142 | 151 | } |
143 | 152 | |
144 | 153 | public loadBundleLibraryWidgets(bundleAlias: string, isSystem: boolean, |
... | ... | @@ -305,6 +314,7 @@ export class WidgetService { |
305 | 314 | this.systemWidgetsBundles = undefined; |
306 | 315 | this.tenantWidgetsBundles = undefined; |
307 | 316 | this.loadWidgetsBundleCacheSubject = undefined; |
317 | + this.widgetTypeInfosCache.clear(); | |
308 | 318 | } |
309 | 319 | |
310 | 320 | } | ... | ... |
... | ... | @@ -242,8 +242,9 @@ |
242 | 242 | </tb-edit-widget> |
243 | 243 | </tb-details-panel> |
244 | 244 | <tb-details-panel *ngIf="!isAddingWidgetClosed && !widgetEditMode" fxFlex |
245 | - headerTitle="{{ | |
246 | - (!widgetsBundle?.title ? 'widget.select-widgets-bundle' : 'dashboard.select-widget-value') | translate: widgetsBundle | |
245 | + headerTitle="{{ isAddingWidget ? | |
246 | + ((!dashboardWidgetSelectComponent?.widgetsBundle ? | |
247 | + 'widget.select-widgets-bundle' : 'dashboard.select-widget-value') | translate: dashboardWidgetSelectComponent?.widgetsBundle) : '' | |
247 | 248 | }}" |
248 | 249 | headerHeightPx="64" |
249 | 250 | [isShowSearch]="true" |
... | ... | @@ -252,34 +253,33 @@ |
252 | 253 | backgroundColor="#cfd8dc" |
253 | 254 | (closeDetails)="onAddWidgetClosed()" |
254 | 255 | (closeSearch)="onCloseSearchBundle()"> |
255 | - <div class="prefix-title-buttons" [fxHide]="!widgetsBundle?.title" style="height: 28px; margin-right: 12px"> | |
256 | - <button class="tb-mat-28" mat-icon-button type="button" (click)="widgetBundleSelected(null)"> | |
256 | + <div class="prefix-title-buttons" [fxShow]="(isAddingWidget && dashboardWidgetSelectComponent?.widgetsBundle) ? true : false" style="height: 28px; margin-right: 12px"> | |
257 | + <button class="tb-mat-28" mat-icon-button type="button" (click)="clearSelectedWidgetBundle()"> | |
257 | 258 | <mat-icon>arrow_back</mat-icon> |
258 | 259 | </button> |
259 | 260 | </div> |
260 | 261 | <div class="search-pane" *ngIf="isAddingWidget" fxLayout="row"> |
261 | 262 | <tb-widgets-bundle-search fxFlex |
262 | 263 | [(ngModel)]="searchBundle" |
263 | - placeholder="{{ (!widgetsBundle?.title ? 'widgets-bundle.search' : 'widget.search') | translate }}" | |
264 | + placeholder="{{ (!dashboardWidgetSelectComponent?.widgetsBundle ? 'widgets-bundle.search' : 'widget.search') | translate }}" | |
264 | 265 | (ngModelChange)="searchBundle = $event"> |
265 | 266 | </tb-widgets-bundle-search> |
266 | 267 | </div> |
267 | 268 | <div class="details-buttons" *ngIf="isAddingWidget"> |
268 | 269 | <button mat-button mat-icon-button type="button" |
269 | - *ngIf="widgetTypes.length > 1" | |
270 | + *ngIf="dashboardWidgetSelectComponent?.widgetTypes.size > 1" | |
270 | 271 | (click)="editWidgetsTypesToDisplay($event)" |
271 | 272 | matTooltip="{{ 'widget.filter' | translate }}" |
272 | 273 | matTooltipPosition="above"> |
273 | 274 | <mat-icon>filter_list</mat-icon> |
274 | 275 | </button> |
275 | 276 | </div> |
276 | - <tb-dashboard-widget-select *ngIf="isAddingWidget" | |
277 | + <tb-dashboard-widget-select #dashboardWidgetSelect | |
278 | + *ngIf="isAddingWidget" | |
277 | 279 | [aliasController]="dashboardCtx.aliasController" |
278 | - [widgetsBundle]="widgetsBundle" | |
279 | 280 | [searchBundle]="searchBundle" |
280 | 281 | [filterWidgetTypes]="filterWidgetTypes" |
281 | - (widgetsTypes)="updateWidgetsTypes($event)" | |
282 | - (widgetsBundleSelected)="widgetBundleSelected($event)" | |
282 | + (widgetsBundleSelected)="widgetBundleSelected()" | |
283 | 283 | (widgetSelected)="addWidgetFromType($event)"> |
284 | 284 | </tb-dashboard-widget-select> |
285 | 285 | </tb-details-panel> | ... | ... |
... | ... | @@ -121,6 +121,7 @@ import { |
121 | 121 | DisplayWidgetTypesPanelData, |
122 | 122 | WidgetTypes |
123 | 123 | } from '@home/components/dashboard-page/widget-types-panel.component'; |
124 | +import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; | |
124 | 125 | |
125 | 126 | // @dynamic |
126 | 127 | @Component({ |
... | ... | @@ -167,9 +168,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
167 | 168 | forceDashboardMobileMode = false; |
168 | 169 | isAddingWidget = false; |
169 | 170 | isAddingWidgetClosed = true; |
170 | - widgetsBundle: WidgetsBundle = null; | |
171 | 171 | searchBundle = ''; |
172 | - widgetTypes: WidgetTypes[] = []; | |
173 | 172 | filterWidgetTypes: widgetType[] = null; |
174 | 173 | |
175 | 174 | isToolbarOpened = false; |
... | ... | @@ -267,6 +266,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
267 | 266 | |
268 | 267 | @ViewChild('tbEditWidget') editWidgetComponent: EditWidgetComponent; |
269 | 268 | |
269 | + @ViewChild('dashboardWidgetSelect') dashboardWidgetSelectComponent: DashboardWidgetSelectComponent; | |
270 | + | |
270 | 271 | constructor(protected store: Store<AppState>, |
271 | 272 | @Inject(WINDOW) private window: Window, |
272 | 273 | private breakpointObserver: BreakpointObserver, |
... | ... | @@ -367,7 +368,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
367 | 368 | this.forceDashboardMobileMode = false; |
368 | 369 | this.isAddingWidget = false; |
369 | 370 | this.isAddingWidgetClosed = true; |
370 | - this.widgetsBundle = null; | |
371 | 371 | |
372 | 372 | this.isToolbarOpened = false; |
373 | 373 | this.isToolbarOpenedAnimate = false; |
... | ... | @@ -885,7 +885,6 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
885 | 885 | |
886 | 886 | addWidgetFromType(widget: WidgetInfo) { |
887 | 887 | this.onAddWidgetClosed(); |
888 | - this.widgetTypes = []; | |
889 | 888 | this.searchBundle = ''; |
890 | 889 | this.widgetComponentService.getWidgetInfo(widget.bundleAlias, widget.typeAlias, widget.isSystemType).subscribe( |
891 | 890 | (widgetTypeInfo) => { |
... | ... | @@ -1153,16 +1152,13 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
1153 | 1152 | return widgetContextActions; |
1154 | 1153 | } |
1155 | 1154 | |
1156 | - widgetBundleSelected(bundle: WidgetsBundle){ | |
1157 | - this.widgetsBundle = bundle; | |
1158 | - this.widgetTypes = []; | |
1155 | + widgetBundleSelected(){ | |
1159 | 1156 | this.searchBundle = ''; |
1160 | 1157 | } |
1161 | 1158 | |
1162 | - updateWidgetsTypes(types: Set<widgetType>) { | |
1163 | - this.widgetTypes = Array.from(types.values()).map(type => { | |
1164 | - return {type, display: true}; | |
1165 | - }); | |
1159 | + clearSelectedWidgetBundle() { | |
1160 | + this.searchBundle = ''; | |
1161 | + this.dashboardWidgetSelectComponent.widgetsBundle = null; | |
1166 | 1162 | } |
1167 | 1163 | |
1168 | 1164 | editWidgetsTypesToDisplay($event: Event) { |
... | ... | @@ -1191,7 +1187,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC |
1191 | 1187 | { |
1192 | 1188 | provide: DISPLAY_WIDGET_TYPES_PANEL_DATA, |
1193 | 1189 | useValue: { |
1194 | - types: this.widgetTypes, | |
1190 | + types: Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => { | |
1191 | + return {type, display: true}; | |
1192 | + }), | |
1195 | 1193 | typesUpdated: (newTypes) => { |
1196 | 1194 | this.filterWidgetTypes = newTypes.filter(type => type.display).map(type => type.type); |
1197 | 1195 | } | ... | ... |
... | ... | @@ -17,7 +17,7 @@ |
17 | 17 | --> |
18 | 18 | <div class="widget-select"> |
19 | 19 | <div *ngIf="widgetsBundle; else bundles"> |
20 | - <div *ngIf="(widgets$ | async)?.length; else emptyBundle" fxFlexFill fxLayoutGap="12px grid" fxLayout="row wrap"> | |
20 | + <div *ngIf="(widgets$ | async)?.length; else loadingWidgets" fxFlexFill fxLayoutGap="12px grid" fxLayout="row wrap"> | |
21 | 21 | <div *ngFor="let widget of widgets$ | async" class="mat-card-container"> |
22 | 22 | <mat-card fxFlexFill fxLayout="row" fxLayoutGap="16px" (click)="onWidgetClicked($event, widget)"> |
23 | 23 | <div class="preview-container" fxFlex="45"> |
... | ... | @@ -33,15 +33,24 @@ |
33 | 33 | </mat-card> |
34 | 34 | </div> |
35 | 35 | </div> |
36 | - <ng-template #emptyBundle> | |
37 | - <span translate | |
38 | - style="display: flex;" | |
39 | - fxLayoutAlign="center center" | |
40 | - class="mat-headline tb-absolute-fill">widgets-bundle.empty</span> | |
36 | + <ng-template #loadingWidgets> | |
37 | + <div *ngIf="loadingWidgets$ | async; else emptyBundle" fxLayout="column" | |
38 | + fxLayoutAlign="center center" class="tb-absolute-fill"> | |
39 | + <span class="mat-headline" style="padding-bottom: 20px;"> | |
40 | + {{ 'widget.loading-widgets' | translate }} | |
41 | + </span> | |
42 | + <mat-spinner color="accent" strokeWidth="5"></mat-spinner> | |
43 | + </div> | |
44 | + <ng-template #emptyBundle> | |
45 | + <span translate | |
46 | + style="display: flex;" | |
47 | + fxLayoutAlign="center center" | |
48 | + class="mat-headline tb-absolute-fill">widgets-bundle.empty</span> | |
49 | + </ng-template> | |
41 | 50 | </ng-template> |
42 | 51 | </div> |
43 | 52 | <ng-template #bundles> |
44 | - <div fxFlexFill fxLayoutGap="12px grid" fxLayout="row wrap"> | |
53 | + <div *ngIf="(widgetsBundles$ | async)?.length; else loadingWidgetBundles" fxFlexFill fxLayoutGap="12px grid" fxLayout="row wrap"> | |
45 | 54 | <div *ngFor="let widgetsBundle of widgetsBundles$ | async" class="mat-card-container"> |
46 | 55 | <mat-card fxFlexFill fxLayout="row" fxLayoutGap="16px" (click)="selectBundle($event, widgetsBundle)"> |
47 | 56 | <div class="preview-container" fxFlex="45"> |
... | ... | @@ -57,5 +66,20 @@ |
57 | 66 | </mat-card> |
58 | 67 | </div> |
59 | 68 | </div> |
69 | + <ng-template #loadingWidgetBundles> | |
70 | + <div *ngIf="loadingWidgetBundles$ | async; else noWidgetBundles" fxLayout="column" | |
71 | + fxLayoutAlign="center center" class="tb-absolute-fill"> | |
72 | + <span class="mat-headline" style="padding-bottom: 20px;"> | |
73 | + {{ 'widgets-bundle.loading-widgets-bundles' | translate }} | |
74 | + </span> | |
75 | + <mat-spinner strokeWidth="5"></mat-spinner> | |
76 | + </div> | |
77 | + <ng-template #noWidgetBundles> | |
78 | + <span translate | |
79 | + style="display: flex;" | |
80 | + fxLayoutAlign="center center" | |
81 | + class="mat-headline tb-absolute-fill">widgets-bundle.no-widgets-bundles-text</span> | |
82 | + </ng-template> | |
83 | + </ng-template> | |
60 | 84 | </ng-template> |
61 | 85 | </div> | ... | ... |
... | ... | @@ -29,8 +29,15 @@ |
29 | 29 | flex: 0 0 100%; |
30 | 30 | max-width: 100%; |
31 | 31 | |
32 | + &:hover { | |
33 | + .mat-card { | |
34 | + box-shadow: 0 2px 6px 6px rgb(0 0 0 / 20%), 0 1px 4px 2px rgb(0 0 0 / 14%), 0 1px 6px 0 rgb(0 0 0 / 12%) | |
35 | + } | |
36 | + } | |
37 | + | |
32 | 38 | .mat-card { |
33 | 39 | cursor: pointer; |
40 | + transition: box-shadow 0.2s; | |
34 | 41 | |
35 | 42 | .preview-container { |
36 | 43 | text-align: center; | ... | ... |
... | ... | @@ -20,8 +20,7 @@ import { IAliasController } from '@core/api/widget-api.models'; |
20 | 20 | import { NULL_UUID } from '@shared/models/id/has-uuid'; |
21 | 21 | import { WidgetService } from '@core/http/widget.service'; |
22 | 22 | import { WidgetInfo, widgetType } from '@shared/models/widget.models'; |
23 | -import { toWidgetInfo } from '@home/models/widget-component.models'; | |
24 | -import { distinctUntilChanged, map, publishReplay, refCount, switchMap, tap } from 'rxjs/operators'; | |
23 | +import { distinctUntilChanged, map, publishReplay, refCount, share, switchMap, tap } from 'rxjs/operators'; | |
25 | 24 | import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; |
26 | 25 | import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; |
27 | 26 | import { isDefinedAndNotNull } from '@core/utils'; |
... | ... | @@ -37,18 +36,28 @@ export class DashboardWidgetSelectComponent implements OnInit { |
37 | 36 | private filterWidgetTypes$ = new BehaviorSubject<Array<widgetType>>(null); |
38 | 37 | private widgetsInfo: Observable<Array<WidgetInfo>>; |
39 | 38 | private widgetsBundleValue: WidgetsBundle; |
40 | - private widgetsType = new Set<widgetType>(); | |
39 | + widgetTypes = new Set<widgetType>(); | |
41 | 40 | |
42 | 41 | widgets$: Observable<Array<WidgetInfo>>; |
42 | + loadingWidgetsSubject: BehaviorSubject<boolean> = new BehaviorSubject(false); | |
43 | + loadingWidgets$ = this.loadingWidgetsSubject.pipe( | |
44 | + share() | |
45 | + ); | |
43 | 46 | widgetsBundles$: Observable<Array<WidgetsBundle>>; |
47 | + loadingWidgetBundlesSubject: BehaviorSubject<boolean> = new BehaviorSubject(true); | |
48 | + loadingWidgetBundles$ = this.loadingWidgetBundlesSubject.pipe( | |
49 | + share() | |
50 | + ); | |
44 | 51 | |
45 | - @Input() | |
46 | 52 | set widgetsBundle(widgetBundle: WidgetsBundle) { |
47 | - this.widgetsInfo = null; | |
48 | - this.widgetsType.clear(); | |
49 | - this.widgetsTypes.emit(this.widgetsType); | |
50 | - this.filterWidgetTypes$.next(null); | |
51 | - this.widgetsBundleValue = widgetBundle; | |
53 | + if (this.widgetsBundleValue !== widgetBundle) { | |
54 | + this.widgetsBundleValue = widgetBundle; | |
55 | + if (widgetBundle === null) { | |
56 | + this.widgetTypes.clear(); | |
57 | + } | |
58 | + this.filterWidgetTypes$.next(null); | |
59 | + this.widgetsInfo = null; | |
60 | + } | |
52 | 61 | } |
53 | 62 | |
54 | 63 | get widgetsBundle(): WidgetsBundle { |
... | ... | @@ -74,14 +83,8 @@ export class DashboardWidgetSelectComponent implements OnInit { |
74 | 83 | @Output() |
75 | 84 | widgetsBundleSelected: EventEmitter<WidgetsBundle> = new EventEmitter<WidgetsBundle>(); |
76 | 85 | |
77 | - @Output() | |
78 | - widgetsTypes: EventEmitter<Set<widgetType>> = new EventEmitter<Set<widgetType>>(); | |
79 | - | |
80 | 86 | constructor(private widgetsService: WidgetService, |
81 | 87 | private sanitizer: DomSanitizer) { |
82 | - } | |
83 | - | |
84 | - ngOnInit(): void { | |
85 | 88 | this.widgetsBundles$ = this.search$.asObservable().pipe( |
86 | 89 | distinctUntilChanged(), |
87 | 90 | switchMap(search => this.fetchWidgetBundle(search)) |
... | ... | @@ -92,27 +95,41 @@ export class DashboardWidgetSelectComponent implements OnInit { |
92 | 95 | ); |
93 | 96 | } |
94 | 97 | |
98 | + ngOnInit(): void { | |
99 | + } | |
100 | + | |
95 | 101 | private getWidgets(): Observable<Array<WidgetInfo>> { |
96 | 102 | if (!this.widgetsInfo) { |
97 | 103 | if (this.widgetsBundle !== null) { |
98 | 104 | const bundleAlias = this.widgetsBundle.alias; |
99 | 105 | const isSystem = this.widgetsBundle.tenantId.id === NULL_UUID; |
106 | + this.loadingWidgetsSubject.next(true); | |
100 | 107 | this.widgetsInfo = this.widgetsService.getBundleWidgetTypeInfos(bundleAlias, isSystem).pipe( |
101 | - map(widgets => widgets.sort((a, b) => b.createdTime - a.createdTime)), | |
102 | - map(widgets => widgets.map((widgetTypeInfo) => { | |
103 | - this.widgetsType.add(widgetTypeInfo.widgetType); | |
104 | - const widget: WidgetInfo = { | |
105 | - isSystemType: isSystem, | |
106 | - bundleAlias, | |
107 | - typeAlias: widgetTypeInfo.alias, | |
108 | - type: widgetTypeInfo.widgetType, | |
109 | - title: widgetTypeInfo.name, | |
110 | - image: widgetTypeInfo.image, | |
111 | - description: widgetTypeInfo.description | |
112 | - }; | |
113 | - return widget; | |
114 | - })), | |
115 | - tap(() => this.widgetsTypes.emit(this.widgetsType)), | |
108 | + map(widgets => { | |
109 | + widgets = widgets.sort((a, b) => b.createdTime - a.createdTime); | |
110 | + const widgetTypes = new Set<widgetType>(); | |
111 | + const widgetInfos = widgets.map((widgetTypeInfo) => { | |
112 | + widgetTypes.add(widgetTypeInfo.widgetType); | |
113 | + const widget: WidgetInfo = { | |
114 | + isSystemType: isSystem, | |
115 | + bundleAlias, | |
116 | + typeAlias: widgetTypeInfo.alias, | |
117 | + type: widgetTypeInfo.widgetType, | |
118 | + title: widgetTypeInfo.name, | |
119 | + image: widgetTypeInfo.image, | |
120 | + description: widgetTypeInfo.description | |
121 | + }; | |
122 | + return widget; | |
123 | + } | |
124 | + ); | |
125 | + setTimeout(() => { | |
126 | + this.widgetTypes = widgetTypes; | |
127 | + }); | |
128 | + return widgetInfos; | |
129 | + }), | |
130 | + tap(() => { | |
131 | + this.loadingWidgetsSubject.next(false); | |
132 | + }), | |
116 | 133 | publishReplay(1), |
117 | 134 | refCount() |
118 | 135 | ); |
... | ... | @@ -147,6 +164,7 @@ export class DashboardWidgetSelectComponent implements OnInit { |
147 | 164 | |
148 | 165 | private getWidgetsBundle(): Observable<Array<WidgetsBundle>> { |
149 | 166 | return this.widgetsService.getAllWidgetsBundles().pipe( |
167 | + tap(() => this.loadingWidgetBundlesSubject.next(false)), | |
150 | 168 | publishReplay(1), |
151 | 169 | refCount() |
152 | 170 | ); | ... | ... |
... | ... | @@ -2266,7 +2266,8 @@ |
2266 | 2266 | "data-overflow": "Widget displays {{count}} out of {{total}} entities", |
2267 | 2267 | "alarm-data-overflow": "Widget displays alarms for {{allowedEntities}} (maximum allowed) entities out of {{totalEntities}} entities", |
2268 | 2268 | "search": "Search widget", |
2269 | - "filter": "Widget filter type" | |
2269 | + "filter": "Widget filter type", | |
2270 | + "loading-widgets": "Loading widgets..." | |
2270 | 2271 | }, |
2271 | 2272 | "widget-action": { |
2272 | 2273 | "header-button": "Widget header button", |
... | ... | @@ -2318,7 +2319,8 @@ |
2318 | 2319 | "invalid-widgets-bundle-file-error": "Unable to import widgets bundle: Invalid widgets bundle data structure.", |
2319 | 2320 | "search": "Search widget bundles", |
2320 | 2321 | "selected-widgets-bundles": "{ count, plural, 1 {1 widgets bundle} other {# widgets bundles} } selected", |
2321 | - "open-widgets-bundle": "Open widgets bundle" | |
2322 | + "open-widgets-bundle": "Open widgets bundle", | |
2323 | + "loading-widgets-bundles": "Loading widgets bundles..." | |
2322 | 2324 | }, |
2323 | 2325 | "widget-config": { |
2324 | 2326 | "data": "Data", | ... | ... |