CB-3703 save product configuration from ui (#2334)

* CB-4560 api for saving product configuration

* CB-4560 save product config in te

* CB-4560 move update product config to mutations

* CB-3703 feat: extend base components

* CB-3703 feat: settings editor

* CB-3703 chore: review fixes

* CB-3703 chore: pr review

* CB-3703 feat: update server configuration icon

* CB-4560 add events

* CB-3703 fix: localization

* CB-3703 chore: localization

---------

Co-authored-by: Alexey <wrouds@gmail.com>
Co-authored-by: Evgenia Bezborodova <139753579+EvgeniaBzzz@users.noreply.github.com>
This commit is contained in:
Ainur
2024-01-31 20:33:05 +03:00
committed by GitHub
co-authored by Alexey Evgenia Bezborodova
parent 11adc7d3d6
commit fdde4348a2
355 changed files with 4429 additions and 2183 deletions
@@ -5,9 +5,5 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { createAction } from '@cloudbeaver/core-view';
export const ACTION_OPEN_APP_SETTINGS = createAction('open-app-settings', {
label: 'settings_panel',
tooltip: 'settings_panel',
});
export const SETTINGS_GROUP_ID_PREFIX = 'settings-group-';
@@ -0,0 +1,108 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { Combobox, FieldCheckbox, InputField, Textarea, useCustomInputValidation, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { ESettingsValueType, type ISettingDescriptionWithScope, PluginManagerService } from '@cloudbeaver/core-plugin';
import type { ISettingsSource } from '@cloudbeaver/core-settings';
import { schemaValidationError } from '@cloudbeaver/core-utils';
interface Props {
source: ISettingsSource;
setting: ISettingDescriptionWithScope;
}
export const Setting = observer<Props>(function Setting({ source, setting }) {
const translate = useTranslate();
const pluginManagerService = useService(PluginManagerService);
const defaultValue = pluginManagerService.getSettings(setting.scope)?.getDefaultValue(setting.key);
const key = `${setting.scope}.${setting.key}`;
const value = source.getValue(key) ?? defaultValue ?? '';
const name = translate(setting.name);
const description = translate(setting.description);
const disabled = false;
const readOnly = false;
const customValidation = useCustomInputValidation(value => {
const result = setting.schema.safeParse(value);
if (result.success) {
return null;
}
return schemaValidationError(result.error).toString();
});
function handleChange(value: any) {
source.setValue(key, value);
}
if (setting.type === ESettingsValueType.Checkbox) {
return (
<FieldCheckbox
id={key}
checked={value}
label={name}
title={name}
caption={description}
disabled={disabled}
readOnly={readOnly}
groupGap
onChange={handleChange}
/>
);
}
if (setting.type === ESettingsValueType.Select) {
const options = setting.options?.map(option => ({ ...option, name: translate(option.name) })) || [];
return (
<Combobox
id={key}
items={options}
keySelector={value => value.id}
valueSelector={value => value.name}
value={value}
title={description}
disabled={disabled}
readOnly={readOnly}
tiny
onSelect={handleChange}
>
{name}
</Combobox>
);
}
if (setting.type === ESettingsValueType.Textarea) {
return (
<Textarea id={key} title={value} labelTooltip={description} value={value} disabled={disabled} readOnly={readOnly} onChange={handleChange}>
{name}
</Textarea>
);
}
return (
<InputField
ref={customValidation}
id={key}
type="text"
title={value}
labelTooltip={description}
value={value}
description={description}
disabled={disabled}
readOnly={readOnly}
small
onChange={handleChange}
>
{name}
</InputField>
);
});
@@ -0,0 +1,63 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import React from 'react';
import { Container, Form, Group, TextPlaceholder, useForm, useTranslate } from '@cloudbeaver/core-blocks';
import { ROOT_SETTINGS_GROUP } from '@cloudbeaver/core-plugin';
import type { ISettingsSource } from '@cloudbeaver/core-settings';
import { useTreeData } from '@cloudbeaver/plugin-navigation-tree';
import { SettingsGroups } from './SettingsGroups/SettingsGroups';
import { SettingsList } from './SettingsList';
import { useSettings } from './useSettings';
export interface ISettingsProps {
source: ISettingsSource;
}
export const Settings = observer<ISettingsProps>(function Settings({ source }) {
const translate = useTranslate();
const settings = useSettings();
const treeData = useTreeData({
rootId: ROOT_SETTINGS_GROUP.id,
getNode(id) {
const group = ROOT_SETTINGS_GROUP.get(id);
return {
name: translate(group!.name),
leaf: !group?.subGroups.length,
};
},
getChildren(id) {
return (ROOT_SETTINGS_GROUP.get(id)?.subGroups || [])
.filter(group => settings.groups.has(group))
.sort((a, b) => a.name.localeCompare(b.name))
.map(group => group.id);
},
load() {
return Promise.resolve();
},
});
if (settings.settings.size === 0) {
return <TextPlaceholder>{translate('plugin_settings_panel_empty')}</TextPlaceholder>;
}
return (
<Container gap overflow noWrap>
<Group style={{ height: '100%', minWidth: '240px' }} box keepSize overflow hidden>
<SettingsGroups treeData={treeData} />
</Group>
<Container style={{ height: '100%' }} fill>
<SettingsList treeData={treeData} source={source} settings={settings.settings} />
</Container>
</Container>
);
});
@@ -1,54 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import React from 'react';
import { Group, GroupTitle, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { PluginManagerService } from '@cloudbeaver/core-plugin';
import { SettingsGroupType, SettingsManagerService, SettingsScopeType } from '@cloudbeaver/core-settings';
import { SettingsInfoForm } from './SettingsInfoForm';
interface Props {
group: SettingsGroupType;
}
export const SettingsGroup = observer<Props>(function SettingsGroup({ group }) {
const settingsManagerService = useService(SettingsManagerService);
const pluginManagerService = useService(PluginManagerService);
const translate = useTranslate();
function getValue(scope: string, scopeType: SettingsScopeType, key: string) {
const settings = pluginManagerService.getSettings(scope, scopeType);
return settings?.getValue(key);
}
const settings = settingsManagerService.settings
.filter(settingsItem => settingsItem.groupId === group.id)
.map(settingsItem => ({
...settingsItem,
value: getValue(settingsItem.scope, settingsItem.scopeType, settingsItem.key),
name: translate(settingsItem.name),
description: translate(settingsItem.description),
options: settingsItem.options?.map(option => ({ ...option, name: translate(option.name) })),
}));
if (settings.length === 0) {
return null;
}
return (
<Group gap vertical>
<GroupTitle keepSize large>
{translate(group.name)}
</GroupTitle>
<SettingsInfoForm fields={settings} readOnly />
</Group>
);
});
@@ -0,0 +1,29 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { forwardRef, useContext } from 'react';
import { TreeNodeControl, TreeNodeExpand, TreeNodeName } from '@cloudbeaver/core-blocks';
import { type NodeControlComponent, TreeContext, TreeDataContext } from '@cloudbeaver/plugin-navigation-tree';
export const GroupNodeControl: NodeControlComponent = observer(
forwardRef(function GroupNodeControl({ nodeId }, ref) {
const data = useContext(TreeDataContext)!;
const tree = useContext(TreeContext)!;
const node = data.getNode(nodeId);
const height = tree.getNodeHeight(nodeId);
return (
<TreeNodeControl ref={ref} style={{ height }}>
<TreeNodeExpand leaf={node.leaf} />
<TreeNodeName title={node.tooltip}>{node.name}</TreeNodeName>
</TreeNodeControl>
);
}),
);
@@ -0,0 +1,29 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { ITreeData, Tree } from '@cloudbeaver/plugin-navigation-tree';
import { getSettingGroupId } from '../getSettingGroupId';
import { groupNodeRenderer } from './groupNodeRenderer';
interface Props {
treeData: ITreeData;
}
export const SettingsGroups = observer<Props>(function SettingsGroups({ treeData }) {
function getNodeHeight(id: string) {
return 24;
}
function handleClick(id: string) {
document.querySelector('#' + getSettingGroupId(id))?.scrollIntoView();
}
return <Tree data={treeData} getNodeHeight={getNodeHeight} nodeRenderers={[groupNodeRenderer]} onNodeClick={handleClick} />;
});
@@ -0,0 +1,16 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { INodeRenderer, Node, NodeComponent } from '@cloudbeaver/plugin-navigation-tree';
import { GroupNodeControl } from './GroupNodeControl';
export const groupNodeRenderer: INodeRenderer = () => GroupNodeRenderer;
const GroupNodeRenderer: NodeComponent = function GroupNodeRenderer(props) {
return <Node {...props} controlRenderer={GroupNodeControl} />;
};
@@ -1,60 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { TextPlaceholder, useTranslate } from '@cloudbeaver/core-blocks';
import type { FormFieldType } from '@cloudbeaver/core-settings';
import { SettingsInfoFormField } from './SettingsInfoFormField';
interface SettingsProps {
key: string;
type: FormFieldType;
disabled?: boolean;
value?: any;
options?: any[];
description?: string;
name?: string;
}
interface SettingsInfoFormProps {
fields: SettingsProps[];
className?: string;
readOnly?: boolean;
onSelect?: (value: any) => void;
onChange?: (value: any) => void;
}
export const SettingsInfoForm = observer<SettingsInfoFormProps>(function SettingsInfoForm({ fields, className, readOnly, onChange, onSelect }) {
const translate = useTranslate();
if (fields.length === 0) {
return <TextPlaceholder>{translate('settings_panel_empty_fields')}</TextPlaceholder>;
}
return (
<>
{fields.map(field => (
<SettingsInfoFormField
key={field.key}
id={field.key}
type={field.type}
value={field.value}
options={field.options}
description={field.description}
name={field.name}
disabled={field.disabled}
readOnly={readOnly}
className={className}
onChange={onChange}
onSelect={onSelect}
/>
))}
</>
);
});
@@ -1,102 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { Combobox, FieldCheckbox, InputField, Textarea } from '@cloudbeaver/core-blocks';
import { FormFieldType } from '@cloudbeaver/core-settings';
export interface SettingsInfoFormFieldProps {
id: string;
type: FormFieldType;
disabled?: boolean;
value?: any;
options?: any[];
description?: string;
name?: string;
className?: string;
readOnly?: boolean;
onSelect?: (value: any) => void;
onChange?: (value: any) => void;
}
export const SettingsInfoFormField = observer<SettingsInfoFormFieldProps>(function SettingsInfoFormField({
id,
type,
value,
description,
disabled,
name,
className,
readOnly,
options,
onSelect,
onChange,
}) {
if (type === FormFieldType.Checkbox) {
return (
<FieldCheckbox id={id} checked={value} title={description} disabled={disabled || readOnly} className={className} tiny onChange={onChange}>
{name ?? ''}
</FieldCheckbox>
);
}
if (type === FormFieldType.Combobox && options !== undefined) {
return (
<Combobox
id={id}
items={options}
keySelector={value => value.id}
valueSelector={value => value.name}
value={value}
title={description}
disabled={disabled}
readOnly={readOnly}
className={className}
tiny
onSelect={onSelect}
>
{name ?? ''}
</Combobox>
);
}
if (type === FormFieldType.Textarea) {
return (
<Textarea
id={id}
title={value}
labelTooltip={description || name}
value={value}
disabled={disabled}
readOnly={readOnly}
className={className}
onChange={onChange}
>
{name ?? ''}
</Textarea>
);
}
return (
<InputField
id={id}
type="text"
title={value}
labelTooltip={description || name}
value={value}
description={description}
disabled={disabled}
readOnly={readOnly}
className={className}
small
onChange={onChange}
>
{name ?? ''}
</InputField>
);
});
@@ -0,0 +1,10 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { importLazyComponent } from '@cloudbeaver/core-utils';
export const Settings = importLazyComponent(() => import('./Settings').then(m => m.Settings));
@@ -0,0 +1,54 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { Container, Group, GroupTitle, useTranslate } from '@cloudbeaver/core-blocks';
import { type ISettingDescriptionWithScope, ROOT_SETTINGS_GROUP, type SettingsGroup } from '@cloudbeaver/core-plugin';
import type { ISettingsSource } from '@cloudbeaver/core-settings';
import type { ITreeData } from '@cloudbeaver/plugin-navigation-tree';
import { getSettingGroupId } from './getSettingGroupId';
import { Setting } from './Setting';
import { useTreeScrollSync } from './useTreeScrollSync';
interface Props {
treeData: ITreeData;
source: ISettingsSource;
settings: Map<SettingsGroup, ISettingDescriptionWithScope<any>[]>;
onSettingsOpen?: (groupId: string) => void;
}
export const SettingsList = observer<Props>(function SettingsList({ treeData, source, settings, onSettingsOpen }) {
const translate = useTranslate();
const list = [];
const groups = [...treeData.getChildren(treeData.rootId)];
const ref = useTreeScrollSync(treeData, onSettingsOpen);
while (groups.length) {
const groupId = groups[0];
groups.splice(0, 1, ...treeData.getChildren(groupId));
const group = ROOT_SETTINGS_GROUP.get(groupId)!;
const groupSettings = settings.get(group);
list.push({ group, settings: groupSettings || [] });
}
return (
<Container ref={ref} style={{ height: '100%' }} gap overflow>
{list.map(({ group, settings }) => (
<Group key={group.id} id={getSettingGroupId(group.id)} vertical gap>
<GroupTitle>{translate(group.name)}</GroupTitle>
{settings.map((setting, i) => (
<Setting key={i} source={source} setting={setting} />
))}
</Group>
))}
</Container>
);
});
@@ -1,6 +0,0 @@
.content {
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
@@ -1,35 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import React from 'react';
import { ColoredContainer, Container, s, useS } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { SettingsManagerService } from '@cloudbeaver/core-settings';
import { SettingsGroup } from './SettingsGroup';
import styles from './SettingsPanelForm.m.css';
export const SettingsPanelForm = observer(function SettingsPanelForm() {
const style = useS(styles);
const settingsManagerService = useService(SettingsManagerService);
const groups = Array.from(settingsManagerService.groups.values());
return (
<div className={s(style, { content: true })}>
<ColoredContainer gap overflow parent>
<Container medium gap vertical overflow>
{groups.map(group => (
<SettingsGroup key={group.id} group={group} />
))}
</Container>
</ColoredContainer>
</div>
);
});
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { SETTINGS_GROUP_ID_PREFIX } from './SETTINGS_GROUP_ID_PREFIX';
export function getSettingGroupId(id: string): string {
return `${SETTINGS_GROUP_ID_PREFIX}${id}`;
}
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { SETTINGS_GROUP_ID_PREFIX } from './SETTINGS_GROUP_ID_PREFIX';
export function getSettingGroupIdFromElementId(id: string): string {
return id.replace(SETTINGS_GROUP_ID_PREFIX, '');
}
@@ -0,0 +1,12 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { SETTINGS_GROUP_ID_PREFIX } from './SETTINGS_GROUP_ID_PREFIX';
export function querySettingsGroups(element: HTMLElement): HTMLElement[] {
return Array.from(element.querySelectorAll<HTMLElement>(`[id^="${SETTINGS_GROUP_ID_PREFIX}"]`));
}
@@ -0,0 +1,56 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed, observable } from 'mobx';
import { useObservableRef } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { ISettingDescriptionWithScope, ROOT_SETTINGS_GROUP, SettingsGroup, SettingsManagerService } from '@cloudbeaver/core-plugin';
interface ISettings {
settings: Map<SettingsGroup, ISettingDescriptionWithScope<any>[]>;
groups: Set<SettingsGroup>;
}
export function useSettings(): ISettings {
const settingsManagerService = useService(SettingsManagerService);
return useObservableRef(
() => ({
get settings() {
const map = new Map();
const settings = this.settingsManagerService.getSettings().sort((a, b) => a.name.localeCompare(b.name));
for (const setting of settings) {
map.set(setting.group, [...(map.get(setting.group) || []), setting]);
}
return map;
},
get groups() {
const groups = new Set(this.settings.keys());
for (const group of groups) {
if (group.parent) {
groups.add(group.parent);
}
}
return groups;
},
groupChildren(id: string) {
return (ROOT_SETTINGS_GROUP.get(id)?.subGroups || []).filter(group => this.groups.has(group)).sort((a, b) => a.name.localeCompare(b.name));
},
}),
{
settings: computed,
groups: computed,
settingsManagerService: observable.ref,
},
{ settingsManagerService },
);
}
@@ -0,0 +1,62 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { useEffect, useRef } from 'react';
import { ROOT_SETTINGS_GROUP } from '@cloudbeaver/core-plugin';
import type { ITreeData } from '@cloudbeaver/plugin-navigation-tree';
import { getSettingGroupIdFromElementId } from './getSettingGroupIdFromElementId';
import { querySettingsGroups } from './querySettingsGroups';
export function useTreeScrollSync(treeData: ITreeData, onSettingsOpen?: (groupId: string) => void): React.RefObject<HTMLDivElement> {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
function syncScroll(e: Event) {
const container = e.target as HTMLDivElement;
const elements = querySettingsGroups(container);
let firstVisibleElement: HTMLElement | undefined;
for (const element of elements) {
if (element.offsetTop + element.offsetHeight > container.scrollTop) {
firstVisibleElement = element;
break;
}
}
if (firstVisibleElement) {
const groupId = getSettingGroupIdFromElementId(firstVisibleElement.id);
let group = ROOT_SETTINGS_GROUP.get(groupId)!;
treeData.updateAllState({ selected: false, expanded: false });
treeData.updateState(groupId, { selected: true });
while (group.parent && group.parent !== ROOT_SETTINGS_GROUP) {
treeData.updateState(group.parent.id, { expanded: true });
group = group.parent;
}
onSettingsOpen?.(groupId);
}
}
element.addEventListener('scroll', syncScroll);
return () => {
element.removeEventListener('scroll', syncScroll);
};
});
return ref;
}
@@ -5,42 +5,15 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { AuthInfoService } from '@cloudbeaver/core-authentication';
import { Bootstrap, injectable } from '@cloudbeaver/core-di';
import { OptionsPanelService } from '@cloudbeaver/core-ui';
import { ActionService, DATA_CONTEXT_MENU, MenuService } from '@cloudbeaver/core-view';
import { TOP_NAV_BAR_SETTINGS_MENU } from '@cloudbeaver/plugin-settings-menu';
import { ACTION_OPEN_APP_SETTINGS } from './actions/ACTION_OPEN_APP_SETTINGS';
import { SettingsPanelForm } from './SettingsPanel/SettingsPanelForm';
@injectable()
export class SettingsPanelPluginBootstrap extends Bootstrap {
constructor(
private readonly menuService: MenuService,
private readonly optionsPanelService: OptionsPanelService,
private readonly authInfoService: AuthInfoService,
private readonly actionService: ActionService,
) {
constructor() {
super();
}
register(): void | Promise<void> {
this.addTopAppMenuItems();
}
register(): void | Promise<void> {}
load(): void | Promise<void> {}
private addTopAppMenuItems() {
this.actionService.addHandler({
id: 'open-app-settings',
isActionApplicable: (context, action) => ACTION_OPEN_APP_SETTINGS === action,
handler: () => this.optionsPanelService.open(() => SettingsPanelForm),
});
this.menuService.addCreator({
isApplicable: context => context.get(DATA_CONTEXT_MENU) === TOP_NAV_BAR_SETTINGS_MENU && !!this.authInfoService.userInfo,
getItems: (context, items) => [...items, ACTION_OPEN_APP_SETTINGS],
});
}
}
@@ -1,6 +1,4 @@
import { settingsPanelPlugin } from './manifest';
export * from './SettingsPanel/SettingsInfoFormField';
export * from './SettingsPanel/SettingsInfoForm';
export * from './SettingsPanel/SettingsLazy';
export default settingsPanelPlugin;
@@ -1,4 +1 @@
export default [
['settings_panel', 'Settings'],
['settings_panel_empty_fields', 'Fields are empty'],
];
export default [['plugin_settings_panel_empty', 'No settings available']];
@@ -1,4 +1 @@
export default [
['settings_panel', 'Settings'],
['settings_panel_empty_fields', 'Fields are empty'],
];
export default [['plugin_settings_panel_empty', 'No settings available']];
@@ -1,4 +1 @@
export default [
['settings_panel', 'Настройки'],
['settings_panel_empty_fields', 'Поля пустые'],
];
export default [['plugin_settings_panel_empty', 'Настройки недоступны']];
@@ -1,4 +1 @@
export default [
['settings_panel', 'Settings'],
['settings_panel_empty_fields', 'Fields are empty'],
];
export default [['plugin_settings_panel_empty', 'No settings available']];