Browse Source

UI: Add export/import and copy/paste of widget actions

Adds the ability to transfer widget actions instead of recreating them by
hand (feature request thingsboard/thingsboard#14986, and per-action
duplicate #11907):
- Export all actions or a single action to a JSON file; import merges them
  into the target widget's actions, dropping sources/types the widget does
  not support, regenerating ids, de-duplicating names and resetting invalid
  cellClick column bindings, with a summary toast.
- Copy all / copy single action to a clipboard buffer (ItemBufferService) and
  paste onto another widget on any dashboard/tab; duplicate an action in place.
- Clear all actions.
Transfer/bulk operations are grouped under a toolbar overflow menu; per-row
copy/duplicate/export under a row overflow menu.
Import also supports pasting raw JSON content in the import dialog.
pull/15920/head
Ruslan 3 weeks ago
parent
commit
fd78cecf4a
  1. 14
      ui-ngx/src/app/core/services/item-buffer.service.ts
  2. 1
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html
  3. 68
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html
  4. 71
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts
  5. 152
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts
  6. 1
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  7. 2
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  8. 39
      ui-ngx/src/app/shared/import-export/import-export.service.ts
  9. 4
      ui-ngx/src/app/shared/models/widget.models.ts
  10. 19
      ui-ngx/src/assets/locale/locale.constant-en_US.json

14
ui-ngx/src/app/core/services/item-buffer.service.ts

@ -28,6 +28,7 @@ import {
DatasourceType,
TargetDeviceType,
Widget,
WidgetActionsMap,
WidgetPosition,
WidgetSize,
widgetType
@ -45,6 +46,7 @@ import { findWidgetModelDefinition } from '@shared/models/widget/widget-model.de
const WIDGET_ITEM = 'widget_item';
const WIDGET_REFERENCE = 'widget_reference';
const WIDGET_ACTIONS = 'widget_actions';
const RULE_NODES = 'rule_nodes';
const RULE_CHAIN_IMPORT = 'rule_chain_import';
@ -180,6 +182,18 @@ export class ItemBufferService {
return this.storeHas(WIDGET_ITEM);
}
public copyWidgetActions(actionsMap: WidgetActionsMap): void {
this.storeSet(WIDGET_ACTIONS, actionsMap);
}
public hasWidgetActions(): boolean {
return this.storeHas(WIDGET_ACTIONS);
}
public pasteWidgetActions(): WidgetActionsMap {
return this.storeGet(WIDGET_ACTIONS);
}
public canPasteWidgetReference(dashboard: Dashboard, state: string, layout: DashboardLayoutId, breakpoint: string): boolean {
const widgetReference: WidgetReference = this.storeGet(WIDGET_REFERENCE);
if (widgetReference) {

1
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html

@ -35,6 +35,7 @@
<tb-manage-widget-actions
[callbacks]="data.callbacks"
[widgetType] = "data.widgetType"
[widgetTitle]="data.widgetTitle"
[actionSources]="actionSources"
[defaultIconColor]="data.defaultIconColor"
[additionalWidgetActionTypes]="data.additionalWidgetActionTypes"

68
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html

@ -21,6 +21,46 @@
<div class="mat-toolbar-tools">
<span class="tb-entity-table-title" translate>widget-config.actions</span>
<span class="flex-1"></span>
<button mat-icon-button [disabled]="isLoading$ | async"
type="button"
[matMenuTriggerFor]="actionsToolbarMenu"
matTooltip="{{ 'action.more' | translate }}"
matTooltipPosition="above">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsToolbarMenu="matMenu">
<button mat-menu-item type="button"
[disabled]="!hasActions"
(click)="copyAllActions(null)">
<mat-icon matMenuItemIcon>copy_all</mat-icon>
<span translate>widget-config.copy-all-actions</span>
</button>
<button mat-menu-item type="button"
[disabled]="!hasActionsInBuffer"
(click)="pasteActions(null)">
<mat-icon matMenuItemIcon>content_paste</mat-icon>
<span translate>widget-config.paste-actions</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item type="button"
(click)="importActions(null)">
<tb-icon matMenuItemIcon>mdi:file-import</tb-icon>
<span translate>widget-config.import-actions</span>
</button>
<button mat-menu-item type="button"
[disabled]="!hasActions"
(click)="exportActions(null)">
<tb-icon matMenuItemIcon>mdi:file-export</tb-icon>
<span translate>widget-config.export-actions</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item type="button"
[disabled]="!hasActions"
(click)="clearActions(null)">
<tb-icon matMenuItemIcon>mdi:broom</tb-icon>
<span translate>widget-config.clear-actions</span>
</button>
</mat-menu>
<button mat-icon-button [disabled]="isLoading$ | async"
type="button"
(click)="addAction($event)"
@ -106,10 +146,36 @@
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef style="min-width: 80px; max-width: 80px; width: 80px">
<mat-header-cell *matHeaderCellDef style="min-width: 120px; max-width: 120px; width: 120px">
</mat-header-cell>
<mat-cell *matCellDef="let action">
<div class="flex flex-1 flex-row items-stretch justify-end">
<button mat-icon-button [disabled]="isLoading$ | async"
type="button"
[matMenuTriggerFor]="actionRowMenu"
matTooltip="{{ 'action.more' | translate }}"
matTooltipPosition="above">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionRowMenu="matMenu">
<button mat-menu-item type="button"
(click)="copyAction(null, action)">
<mat-icon matMenuItemIcon>copy_all</mat-icon>
<span translate>widget-config.copy-action</span>
</button>
@if (actionSources[action.actionSourceId]?.multiple) {
<button mat-menu-item type="button"
(click)="duplicateAction(null, action)">
<mat-icon matMenuItemIcon>content_copy</mat-icon>
<span translate>widget-config.duplicate-action</span>
</button>
}
<button mat-menu-item type="button"
(click)="exportAction(null, action)">
<tb-icon matMenuItemIcon>mdi:file-export</tb-icon>
<span translate>widget-config.export-action</span>
</button>
</mat-menu>
<button mat-icon-button [disabled]="isLoading$ | async"
type="button"
matTooltip="{{ 'widget-config.edit-action' | translate }}"

71
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts

@ -18,6 +18,9 @@ import {
CellClickColumnInfo,
WidgetActionDescriptor,
WidgetActionSource,
WidgetActionsMap,
WidgetActionType,
widgetActionTypes,
widgetActionTypeTranslationMap
} from '@app/shared/models/widget.models';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
@ -27,7 +30,7 @@ import { TranslateService } from '@ngx-translate/core';
import { PageLink } from '@shared/models/page/page-link';
import { catchError, map } from 'rxjs/operators';
import { UtilsService } from '@core/services/utils.service';
import { deepClone } from '@core/utils';
import { deepClone, guid, isDefinedAndNotNull } from '@core/utils';
export interface WidgetActionCallbacks {
fetchDashboardStates: (query: string) => Array<string>;
@ -35,7 +38,7 @@ export interface WidgetActionCallbacks {
}
export interface WidgetActionsData {
actionsMap: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
actionsMap: WidgetActionsMap;
actionSources: {[actionSourceId: string]: WidgetActionSource};
}
@ -53,6 +56,68 @@ export const toWidgetActionDescriptor = (action: WidgetActionDescriptorInfo): Wi
return copy;
};
export interface WidgetActionsImportResult {
imported: number;
skipped: number;
columnIndexesReset: number;
}
export const mergeWidgetActionsMap = (targetMap: WidgetActionsMap, importedMap: WidgetActionsMap,
actionSources: {[actionSourceId: string]: WidgetActionSource},
additionalWidgetActionTypes: WidgetActionType[],
fetchCellClickColumnsCount: () => number): WidgetActionsImportResult => {
const result: WidgetActionsImportResult = {imported: 0, skipped: 0, columnIndexesReset: 0};
const allowedActionTypes = widgetActionTypes.concat(additionalWidgetActionTypes || []);
let cellClickColumnsCount: number;
for (const actionSourceId of Object.keys(importedMap)) {
const actionSource = Object.prototype.hasOwnProperty.call(actionSources, actionSourceId) ?
actionSources[actionSourceId] : null;
const importedActions = importedMap[actionSourceId];
if (!actionSource) {
result.skipped += importedActions.length;
continue;
}
for (const importedAction of importedActions) {
let targetActions = targetMap[actionSourceId];
if (!allowedActionTypes.includes(importedAction.type) ||
(!actionSource.multiple && targetActions?.length)) {
result.skipped++;
continue;
}
const action = deepClone(importedAction);
action.id = guid();
delete action.displayName;
delete action.customImports;
if (actionSourceId === 'cellClick' && isDefinedAndNotNull(action.columnIndex)) {
cellClickColumnsCount ??= fetchCellClickColumnsCount();
if (!Number.isInteger(action.columnIndex) || action.columnIndex < 0 ||
cellClickColumnsCount - 1 < action.columnIndex ||
targetActions?.some(targetAction => targetAction.columnIndex === action.columnIndex)) {
action.columnIndex = null;
result.columnIndexesReset++;
}
}
if (!targetActions) {
targetActions = [];
targetMap[actionSourceId] = targetActions;
}
action.name = uniqueWidgetActionName(action.name, targetActions);
targetActions.push(action);
result.imported++;
}
}
return result;
};
const uniqueWidgetActionName = (name: string, actions: Array<WidgetActionDescriptor>): string => {
let result = name;
let index = 2;
while (actions.some(action => action.name === result)) {
result = `${name} (${index++})`;
}
return result;
};
export class WidgetActionsDatasource implements DataSource<WidgetActionDescriptorInfo> {
private actionsSubject = new BehaviorSubject<WidgetActionDescriptorInfo[]>([]);
@ -62,7 +127,7 @@ export class WidgetActionsDatasource implements DataSource<WidgetActionDescripto
private allActions: Observable<Array<WidgetActionDescriptorInfo>>;
private actionsMap: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
private actionsMap: WidgetActionsMap;
private actionSources: {[actionSourceId: string]: WidgetActionSource};
constructor(private translate: TranslateService,

152
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts

@ -39,6 +39,7 @@ import { MatSort } from '@angular/material/sort';
import { fromEvent, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, first, tap } from 'rxjs/operators';
import {
mergeWidgetActionsMap,
toWidgetActionDescriptor,
WidgetActionCallbacks,
WidgetActionDescriptorInfo,
@ -46,7 +47,13 @@ import {
WidgetActionsDatasource
} from '@home/components/widget/action/manage-widget-actions.component.models';
import { UtilsService } from '@core/services/utils.service';
import { WidgetActionDescriptor, WidgetActionSource, WidgetActionType, widgetType } from '@shared/models/widget.models';
import {
WidgetActionDescriptor,
WidgetActionSource,
WidgetActionsMap,
WidgetActionType,
widgetType
} from '@shared/models/widget.models';
import {
WidgetActionDialogComponent,
WidgetActionDialogData
@ -55,6 +62,9 @@ import { deepClone } from '@core/utils';
import { hidePageSizePixelValue } from '@shared/models/constants';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { DomSanitizer } from '@angular/platform-browser';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { ItemBufferService } from '@core/services/item-buffer.service';
@Component({
selector: 'tb-manage-widget-actions',
@ -75,6 +85,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
@Input() widgetType: widgetType;
@Input() widgetTitle: string;
@Input() defaultIconColor: string;
@Input() callbacks: WidgetActionCallbacks;
@ -90,7 +102,7 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
dataSource: WidgetActionsDatasource;
dragDisabled = true;
private actionsMap: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
private actionsMap: WidgetActionsMap;
private viewsInited = false;
private dirtyValue = false;
private widgetResize$: ResizeObserver;
@ -110,7 +122,9 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
private cd: ChangeDetectorRef,
private elementRef: ElementRef,
private zone: NgZone,
private sanitizer: DomSanitizer) {
private sanitizer: DomSanitizer,
private importExportService: ImportExportService,
private itembuffer: ItemBufferService) {
super();
const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC };
this.pageLink = new PageLink(10, 0, null, sortOrder);
@ -194,6 +208,136 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
this.openWidgetActionDialog($event);
}
get hasActions(): boolean {
return this.actionsMap && Object.keys(this.actionsMap).some(id => this.actionsMap[id]?.length > 0);
}
importActions($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.importExportService.importWidgetActions().subscribe((importedActionsMap) => {
if (importedActionsMap) {
this.applyImportedActions(importedActionsMap, 'widget-config.import-actions-imported');
}
});
}
copyAction($event: Event, action: WidgetActionDescriptorInfo) {
if ($event) {
$event.stopPropagation();
}
this.itembuffer.copyWidgetActions({[action.actionSourceId]: [toWidgetActionDescriptor(action)]});
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget-config.action-copied'),
type: 'success', duration: 1500, target: 'dashboardRoot'}));
}
pasteActions($event: Event) {
if ($event) {
$event.stopPropagation();
}
const actionsMap = this.itembuffer.pasteWidgetActions();
if (actionsMap) {
this.applyImportedActions(actionsMap, 'widget-config.paste-actions-pasted');
}
}
get hasActionsInBuffer(): boolean {
return this.itembuffer.hasWidgetActions();
}
private applyImportedActions(importedActionsMap: WidgetActionsMap, appliedMessage: string) {
const result = mergeWidgetActionsMap(this.actionsMap, importedActionsMap, this.actionSources,
this.additionalWidgetActionTypes, () => this.callbacks.fetchCellClickColumns()?.length ?? 0);
if (result.imported) {
this.onActionsUpdated();
}
const messageParts = [this.translate.instant(appliedMessage, {count: result.imported})];
if (result.skipped) {
messageParts.push(this.translate.instant('widget-config.import-actions-skipped', {count: result.skipped}));
}
if (result.columnIndexesReset) {
messageParts.push(this.translate.instant('widget-config.import-actions-columns-reset', {count: result.columnIndexesReset}));
}
const hasWarnings = !!(result.skipped || result.columnIndexesReset);
this.store.dispatch(new ActionNotificationShow(
{message: messageParts.join('<br/>'),
type: hasWarnings ? 'warn' : 'success',
duration: hasWarnings ? 6000 : 2000,
target: 'dashboardRoot'}));
}
duplicateAction($event: Event, action: WidgetActionDescriptorInfo) {
if ($event) {
$event.stopPropagation();
}
const result = mergeWidgetActionsMap(this.actionsMap,
{[action.actionSourceId]: [toWidgetActionDescriptor(action)]}, this.actionSources,
this.additionalWidgetActionTypes, () => this.callbacks.fetchCellClickColumns()?.length ?? 0);
if (result.imported) {
this.onActionsUpdated();
}
}
clearActions($event: Event) {
if ($event) {
$event.stopPropagation();
}
const title = this.translate.instant('widget-config.clear-actions-title');
const content = this.translate.instant('widget-config.clear-actions-text');
this.dialogs.confirm(title, content,
this.translate.instant('action.no'),
this.translate.instant('action.yes'), true).subscribe(
(res) => {
if (res) {
for (const actionSourceId of Object.keys(this.actionsMap)) {
delete this.actionsMap[actionSourceId];
}
this.onActionsUpdated();
}
});
}
exportActions($event: Event) {
if ($event) {
$event.stopPropagation();
}
const fileName = this.widgetTitle ? `${this.widgetTitle}_actions` : 'widget_actions';
this.importExportService.exportWidgetActions(this.prepareActionsMap(), fileName);
}
copyAllActions($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.itembuffer.copyWidgetActions(this.prepareActionsMap());
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget-config.actions-copied'),
type: 'success', duration: 1500, target: 'dashboardRoot'}));
}
private prepareActionsMap(): WidgetActionsMap {
const preparedActionsMap: WidgetActionsMap = {};
for (const actionSourceId of Object.keys(this.actionsMap)) {
const actions = this.actionsMap[actionSourceId];
if (actions?.length) {
preparedActionsMap[actionSourceId] = actions;
}
}
return preparedActionsMap;
}
exportAction($event: Event, action: WidgetActionDescriptorInfo) {
if ($event) {
$event.stopPropagation();
}
const exportedActionsMap: WidgetActionsMap = {
[action.actionSourceId]: [toWidgetActionDescriptor(action)]
};
this.importExportService.exportWidgetActions(exportedActionsMap, `${action.name}_action`);
}
editAction($event: Event, action: WidgetActionDescriptorInfo) {
this.openWidgetActionDialog($event, action);
}
@ -347,7 +491,7 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni
this.disabled = isDisabled;
}
writeValue(actions?: {[actionSourceId: string]: Array<WidgetActionDescriptor>}): void {
writeValue(actions?: WidgetActionsMap): void {
this.actionsMap = actions ?? {};
setTimeout(() => {
if (!this.destroyed) {

1
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -183,6 +183,7 @@
<tb-manage-widget-actions
[callbacks]="widgetConfigCallbacks"
[widgetType] = "modelValue.widgetType"
[widgetTitle]="modelValue.widgetName"
[defaultIconColor]="widgetSettings.get('color').value"
[actionSources]="modelValue.actionSources"
[additionalWidgetActionTypes]="modelValue.typeParameters?.additionalWidgetActionTypes"

2
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -938,7 +938,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe
private fetchCellClickColumns(): Array<CellClickColumnInfo> {
if (this.modelValue) {
const configuredColumns = new Array<CellClickColumnInfo>();
if (this.modelValue.config?.datasources[0]?.dataKeys?.length) {
if (this.modelValue.config?.datasources?.[0]?.dataKeys?.length) {
const {
displayEntityLabel,
displayEntityName,

39
ui-ngx/src/app/shared/import-export/import-export.service.ts

@ -37,7 +37,7 @@ import { forkJoin, Observable, of, Subject } from 'rxjs';
import { catchError, map, mergeMap, switchMap, take, tap } from 'rxjs/operators';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { EntityService } from '@core/http/entity.service';
import { Widget, WidgetSize, WidgetTypeDetails } from '@shared/models/widget.models';
import { Widget, WidgetActionsMap, WidgetActionType, WidgetSize, WidgetTypeDetails } from '@shared/models/widget.models';
import { ItemBufferService, WidgetItem } from '@core/services/item-buffer.service';
import {
BulkImportRequest,
@ -146,6 +146,27 @@ export class ImportExportService {
);
}
public exportWidgetActions(actionsMap: WidgetActionsMap, fileName: string) {
this.exportToPc(actionsMap, fileName, true);
}
public importWidgetActions(): Observable<WidgetActionsMap> {
return this.openImportDialog('widget-config.import-actions', 'widget-config.actions-file',
true, 'widget-config.actions-json-content').pipe(
map((actionsMap: WidgetActionsMap) => {
if (!this.validateImportedWidgetActions(actionsMap)) {
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget-config.invalid-actions-file-error'),
type: 'error'}));
throw new Error('Invalid widget actions file');
} else {
return actionsMap;
}
}),
catchError(() => of(null))
);
}
public exportImage(type: ImageResourceType, key: string) {
this.imageService.exportImage(type, key).subscribe(
{
@ -994,6 +1015,22 @@ export class ImportExportService {
}
}
private validateImportedWidgetActions(actionsMap: WidgetActionsMap): boolean {
if (!isObject(actionsMap) || Array.isArray(actionsMap)) {
return false;
}
const actionSourceIds = Object.keys(actionsMap);
if (!actionSourceIds.some(actionSourceId => Array.isArray(actionsMap[actionSourceId]) && actionsMap[actionSourceId].length)) {
return false;
}
return !actionSourceIds.some(actionSourceId => {
const actions = actionsMap[actionSourceId];
return !Array.isArray(actions) || actions.some(action => !isObject(action)
|| !isNotEmptyStr(action.name)
|| !Object.values(WidgetActionType).includes(action.type));
});
}
private validateImportedImage(image: ImageExportData): boolean {
return !(!isNotEmptyStr(image.data)
|| !isNotEmptyStr(image.title)

4
ui-ngx/src/app/shared/models/widget.models.ts

@ -864,6 +864,8 @@ export interface WidgetActionDescriptor extends WidgetAction {
columnIndex?: number;
}
export type WidgetActionsMap = {[actionSourceId: string]: Array<WidgetActionDescriptor>};
export const actionDescriptorToAction = (descriptor: WidgetActionDescriptor): WidgetAction => {
const result: WidgetActionDescriptor = {...descriptor};
delete result.id;
@ -953,7 +955,7 @@ export interface WidgetConfig {
decimals?: number;
noDataDisplayMessage?: string;
pageSize?: number;
actions?: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
actions?: WidgetActionsMap;
settings?: WidgetSettings;
alarmSource?: Datasource;
alarmFilterConfig?: AlarmFilterConfig;

19
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -7922,6 +7922,25 @@
"add-action": "Add action",
"search-actions": "Search actions",
"no-actions-text": "No actions found",
"import-actions": "Import actions",
"export-actions": "Export actions",
"export-action": "Export action",
"duplicate-action": "Duplicate action",
"copy-action": "Copy action",
"copy-all-actions": "Copy all actions",
"action-copied": "Action copied",
"actions-copied": "Actions copied",
"paste-actions": "Paste actions",
"clear-actions": "Clear all actions",
"clear-actions-title": "Clear all actions",
"clear-actions-text": "Are you sure you want to remove all widget actions?",
"actions-file": "Widget actions file",
"actions-json-content": "Actions JSON content",
"invalid-actions-file-error": "Unable to import widget actions: Invalid widget actions data structure.",
"import-actions-imported": "{ count, plural, =0 {No actions were imported} =1 {1 action was imported} other {# actions were imported} }",
"paste-actions-pasted": "{ count, plural, =0 {No actions were pasted} =1 {1 action was pasted} other {# actions were pasted} }",
"import-actions-skipped": "{ count, plural, =1 {1 action was skipped as not applicable or already configured for this widget} other {# actions were skipped as not applicable or already configured for this widget} }",
"import-actions-columns-reset": "{ count, plural, =1 {1 imported cell click action has no column assigned. Edit the action to assign a column.} other {# imported cell click actions have no column assigned. Edit the actions to assign a column.} }",
"action-source": "Action source",
"select-action-source": "Select action source",
"action-source-required": "Action source is required.",

Loading…
Cancel
Save