mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
feat: CB-907 JSON, XML, HTML value panel
This commit is contained in:
@@ -10,6 +10,10 @@ import { observer } from 'mobx-react-lite';
|
||||
import { Controlled as CodeMirror } from 'react-codemirror2';
|
||||
import styled, { use } from 'reshadow';
|
||||
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/mode/xml/xml';
|
||||
import 'codemirror/mode/htmlembedded/htmlembedded';
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed';
|
||||
import 'codemirror/mode/meta';
|
||||
import 'codemirror/addon/hint/show-hint';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePopper } from 'react-popper';
|
||||
import styled, { css } from 'reshadow';
|
||||
|
||||
import { InlineEditor } from '@cloudbeaver/core-app';
|
||||
import { ResultSetFormatAction } from '@cloudbeaver/plugin-data-viewer';
|
||||
|
||||
import { DataGridContext, IColumnResizeInfo } from '../DataGridContext';
|
||||
import { TableDataContext } from '../TableDataContext';
|
||||
@@ -50,6 +51,7 @@ export const CellEditor = observer<Pick<EditorProps<any, any>, 'rowIdx' | 'row'
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
const [popperRef, setPopperRef] = useState<HTMLDivElement | null>(null);
|
||||
const formatter = dataGridContext?.model.source.getAction(dataGridContext.resultIndex, ResultSetFormatAction);
|
||||
const popper = usePopper(elementRef, popperRef, {
|
||||
placement: 'right',
|
||||
});
|
||||
@@ -85,7 +87,7 @@ export const CellEditor = observer<Pick<EditorProps<any, any>, 'rowIdx' | 'row'
|
||||
}
|
||||
});
|
||||
|
||||
const value = row[column.key];
|
||||
const value = formatter?.get(row[column.key]);
|
||||
const numericCell = typeof tableDataContext?.getCellValue(rowIdx, column.key) === 'number';
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export const CellFormatter: React.FC<FormatterProps> = observer(function CellFor
|
||||
const context = useContext(DataGridContext);
|
||||
const editingContext = useContext(EditingContext);
|
||||
const formatter = context?.model.source.getAction(context.resultIndex, ResultSetFormatAction);
|
||||
const rawValue = row[column.key];
|
||||
const rawValue = formatter?.get(row[column.key]) ?? row[column.key];
|
||||
const classes = getClasses(rawValue);
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const value = formatter?.toString(rawValue) ?? String(rawValue);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"lint-fix": "eslint ./src/ --ext .ts,.tsx --fix"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudbeaver/plugin-codemirror": "^0.1.0",
|
||||
"@cloudbeaver/plugin-object-viewer": "^0.1.0",
|
||||
"@cloudbeaver/core-app": "^0.1.0",
|
||||
"@cloudbeaver/core-blocks": "^0.1.0",
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ export interface IDatabaseDataFormatAction<TKey, TResult extends IDatabaseDataRe
|
||||
extends IDatabaseDataAction<any, TResult> {
|
||||
isReadOnly: (key: TKey) => boolean;
|
||||
get: (value: any) => any;
|
||||
getText: (value: any) => string | null;
|
||||
isNull: (value: any) => boolean;
|
||||
toString: (value: any) => string;
|
||||
}
|
||||
|
||||
+3
-6
@@ -14,6 +14,7 @@ import { databaseDataAction } from '../DatabaseDataActionDecorator';
|
||||
import type { IDatabaseDataResultAction } from '../IDatabaseDataResultAction';
|
||||
import type { IResultSetContentValue } from './IResultSetContentValue';
|
||||
import type { IResultSetElementKey } from './IResultSetElementKey';
|
||||
import { isResultSetContentValue } from './isResultSetContentValue';
|
||||
|
||||
@databaseDataAction()
|
||||
export class ResultSetDataAction extends DatabaseDataAction<any, IDatabaseResultSet>
|
||||
@@ -31,14 +32,10 @@ export class ResultSetDataAction extends DatabaseDataAction<any, IDatabaseResult
|
||||
getContent(cell: IResultSetElementKey): IResultSetContentValue | null {
|
||||
const value = this.getCellValue(cell);
|
||||
|
||||
if (
|
||||
value !== null
|
||||
&& typeof value === 'object'
|
||||
&& '$type' in value
|
||||
&& value.$type === 'content'
|
||||
) {
|
||||
if (isResultSetContentValue(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+24
-13
@@ -13,6 +13,8 @@ import type { IDatabaseResultSet } from '../../IDatabaseResultSet';
|
||||
import { databaseDataAction } from '../DatabaseDataActionDecorator';
|
||||
import type { IDatabaseDataFormatAction } from '../IDatabaseDataFormatAction';
|
||||
import type { IResultSetElementKey } from './IResultSetElementKey';
|
||||
import { isResultSetContentValue } from './isResultSetContentValue';
|
||||
import { ResultSetDataAction } from './ResultSetDataAction';
|
||||
|
||||
@databaseDataAction()
|
||||
export class ResultSetFormatAction extends DatabaseDataAction<any, IDatabaseResultSet>
|
||||
@@ -23,22 +25,26 @@ export class ResultSetFormatAction extends DatabaseDataAction<any, IDatabaseResu
|
||||
let columnReadonly = false;
|
||||
let cellReadonly = false;
|
||||
|
||||
if (key.column !== undefined && this.result.data?.columns) {
|
||||
columnReadonly = this.result.data.columns[key.column].readOnly;
|
||||
const data = this.getAction(ResultSetDataAction);
|
||||
|
||||
if (key.column !== undefined) {
|
||||
columnReadonly = data.getColumn(key.column)?.readOnly || false;
|
||||
}
|
||||
|
||||
if (key.row !== undefined && key.column !== undefined && this.result.data?.rows) {
|
||||
const value = this.result.data.rows[key.row][key.column];
|
||||
cellReadonly = this.isValueReadonly(value);
|
||||
const value = data.getCellValue(key);
|
||||
|
||||
if (isResultSetContentValue(value)) {
|
||||
cellReadonly = (
|
||||
value.binary !== undefined
|
||||
|| value.contentLength !== value.text?.length
|
||||
);
|
||||
} else if (value !== null && typeof value === 'object') {
|
||||
cellReadonly = true;
|
||||
}
|
||||
|
||||
return columnReadonly || cellReadonly;
|
||||
}
|
||||
|
||||
isValueReadonly(value: any): boolean {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
isNull(value: any): boolean {
|
||||
return this.get(value) === null;
|
||||
}
|
||||
@@ -48,11 +54,16 @@ export class ResultSetFormatAction extends DatabaseDataAction<any, IDatabaseResu
|
||||
if ('text' in value) {
|
||||
return value.text;
|
||||
} else if ('value' in value) {
|
||||
if (value.value !== null && typeof value.value === 'object') {
|
||||
return JSON.stringify(value.value);
|
||||
}
|
||||
return value.value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getText(value: any): string {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -60,7 +71,7 @@ export class ResultSetFormatAction extends DatabaseDataAction<any, IDatabaseResu
|
||||
}
|
||||
|
||||
toString(value: any): string {
|
||||
value = this.get(value);
|
||||
value = this.getText(this.get(value));
|
||||
|
||||
if (typeof value === 'string' && value.length > 1000) {
|
||||
return value.split('').map(v => (v.charCodeAt(0) < 32 ? ' ' : v)).join('');
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2021 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 type { IResultSetContentValue } from './IResultSetContentValue';
|
||||
|
||||
export function isResultSetContentValue(value: any): value is IResultSetContentValue {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
&& '$type' in value
|
||||
&& value.$type === 'content';
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { makeObservable, observable } from 'mobx';
|
||||
|
||||
import { Executor, IExecutor } from '@cloudbeaver/core-executor';
|
||||
|
||||
import { isResultSetContentValue } from './Actions/ResultSet/isResultSetContentValue';
|
||||
import { DataUpdateType, IDatabaseDataEditor, IDatabaseDataEditorActionsData, IDatabaseDataResultEditor, IDataUpdate, IResultEditingDiff } from './IDatabaseDataEditor';
|
||||
import type { IDatabaseDataResult } from './IDatabaseDataResult';
|
||||
|
||||
@@ -127,6 +128,17 @@ export class DatabaseDataEditor<TResult extends IDatabaseDataResult> implements
|
||||
*/
|
||||
setCell(result: TResult, row: number, column: number, value: any): void {
|
||||
const diff = this.getOrCreateDiff(result, row);
|
||||
const prevValue = diff.source[column];
|
||||
|
||||
if (isResultSetContentValue(prevValue)) {
|
||||
if ('text' in prevValue) {
|
||||
value = {
|
||||
...prevValue,
|
||||
text: String(value),
|
||||
contentLength: String(value).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
diff.update[column] = value;
|
||||
|
||||
@@ -284,6 +296,12 @@ export class DatabaseDataEditor<TResult extends IDatabaseDataResult> implements
|
||||
return Number(valueA) === Number(valueB);
|
||||
}
|
||||
|
||||
if (isResultSetContentValue(valueA) && isResultSetContentValue(valueB)) {
|
||||
if ('text' in valueA && 'text' in valueB) {
|
||||
return valueA.text === valueB.text;
|
||||
}
|
||||
}
|
||||
|
||||
return valueA === valueB;
|
||||
}
|
||||
|
||||
|
||||
+134
-23
@@ -6,29 +6,88 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import styled from 'reshadow';
|
||||
import styled, { css } from 'reshadow';
|
||||
|
||||
import { BASE_CONTAINERS_STYLES, TabContainerPanelComponent, TextareaNew } from '@cloudbeaver/core-blocks';
|
||||
import { useStyles } from '@cloudbeaver/core-theming';
|
||||
import { css } from '@reshadow/react';
|
||||
import { BASE_CONTAINERS_STYLES, TabContainerPanelComponent, TabList, TabsState, TextareaNew, UNDERLINE_TAB_STYLES, useObjectRef } from '@cloudbeaver/core-blocks';
|
||||
import { useService } from '@cloudbeaver/core-di';
|
||||
import { composes, useStyles } from '@cloudbeaver/core-theming';
|
||||
import { CodeEditorLoader } from '@cloudbeaver/plugin-codemirror';
|
||||
|
||||
import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetElementKey';
|
||||
import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue';
|
||||
import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction';
|
||||
import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction';
|
||||
import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet';
|
||||
import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService';
|
||||
import { TextValuePresentationService } from './TextValuePresentationService';
|
||||
|
||||
const styles = css`
|
||||
TextareaNew {
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
const styles = composes(
|
||||
css`
|
||||
TabList {
|
||||
composes: theme-border-color-background theme-background-background from global;
|
||||
}
|
||||
Tab {
|
||||
composes: theme-ripple theme-background-surface theme-text-text-primary-on-light from global;
|
||||
}
|
||||
`,
|
||||
css`
|
||||
container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
content-type {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
TextareaNew {
|
||||
flex: 1;
|
||||
}
|
||||
CodeEditorLoader {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
TabList {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
|
||||
& Tab {
|
||||
border-bottom: 0;
|
||||
|
||||
&:global([aria-selected="false"]) {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelProps<any, IDatabaseResultSet>> = observer(function TextValuePresentation({
|
||||
model,
|
||||
resultIndex,
|
||||
}) {
|
||||
const textValuePresentationService = useService(TextValuePresentationService);
|
||||
const style = useStyles(styles, BASE_CONTAINERS_STYLES, UNDERLINE_TAB_STYLES);
|
||||
const state = useObjectRef({
|
||||
currentContentType: 'text/plain',
|
||||
lastContentType: 'text/plain',
|
||||
|
||||
setContentType(type: string) {
|
||||
this.currentContentType = type;
|
||||
},
|
||||
setDefaultContentType(type: string) {
|
||||
this.currentContentType = type;
|
||||
this.lastContentType = type;
|
||||
},
|
||||
}, {}, {
|
||||
currentContentType: observable,
|
||||
lastContentType: observable,
|
||||
}, ['setContentType', 'setDefaultContentType']);
|
||||
const result = model.getResult(resultIndex);
|
||||
const selection = model.source.getAction(resultIndex, ResultSetSelectAction);
|
||||
|
||||
@@ -37,6 +96,7 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
|
||||
|
||||
let value: any;
|
||||
let stringValue: string | undefined;
|
||||
let contentType = 'text/plain';
|
||||
let firstSelectedCell: Required<IResultSetElementKey> | undefined;
|
||||
let readonly = true;
|
||||
|
||||
@@ -44,31 +104,82 @@ export const TextValuePresentation: TabContainerPanelComponent<IDataValuePanelPr
|
||||
const format = model.source.getAction(resultIndex, ResultSetFormatAction);
|
||||
|
||||
firstSelectedCell = selectedCells[0] || focusCell;
|
||||
|
||||
value = model.source
|
||||
.getEditor(resultIndex)
|
||||
.getCell(firstSelectedCell.row, firstSelectedCell.column);
|
||||
|
||||
stringValue = format.get(value);
|
||||
stringValue = format.getText(format.get(value));
|
||||
readonly = format.isReadOnly(firstSelectedCell);
|
||||
|
||||
if (isResultSetContentValue(value)) {
|
||||
if (value.contentType) {
|
||||
contentType = value.contentType;
|
||||
|
||||
if (contentType === 'text/json') {
|
||||
contentType = 'application/json';
|
||||
}
|
||||
|
||||
if (!textValuePresentationService.tabs.has(contentType)) {
|
||||
contentType = 'text/plain';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (firstSelectedCell) {
|
||||
readonly = model.isReadonly() || readonly;
|
||||
|
||||
if (contentType !== state.lastContentType) {
|
||||
state.setDefaultContentType(contentType);
|
||||
}
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (firstSelectedCell && !readonly) {
|
||||
model.source
|
||||
.getEditor(resultIndex)
|
||||
.setCell(firstSelectedCell.row, firstSelectedCell.column, value);
|
||||
.setCell(firstSelectedCell.row, firstSelectedCell.column, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return styled(useStyles(styles, BASE_CONTAINERS_STYLES))(
|
||||
<TextareaNew
|
||||
name="value"
|
||||
rows={3}
|
||||
value={stringValue}
|
||||
disabled={stringValue === undefined}
|
||||
readOnly={model.isReadonly() || readonly}
|
||||
embedded
|
||||
onChange={handleChange}
|
||||
/>
|
||||
const useCodeEditor = state.currentContentType !== 'text/plain';
|
||||
|
||||
return styled(style)(
|
||||
<container>
|
||||
<content-type>
|
||||
<TabsState
|
||||
container={textValuePresentationService.tabs}
|
||||
currentTabId={state.currentContentType}
|
||||
lazy
|
||||
onChange={tab => state.setContentType(tab.tabId)}
|
||||
>
|
||||
<TabList style={[styles, UNDERLINE_TAB_STYLES]} />
|
||||
</TabsState>
|
||||
</content-type>
|
||||
{useCodeEditor ? (
|
||||
<CodeEditorLoader
|
||||
readonly={readonly}
|
||||
value={stringValue || ''}
|
||||
options={{
|
||||
mode: state.currentContentType,
|
||||
theme: 'material',
|
||||
lineNumbers: true,
|
||||
indentWithTabs: true,
|
||||
smartIndent: true,
|
||||
lineWrapping: true,
|
||||
}}
|
||||
onBeforeChange={(editor, data, value) => handleChange(value)}
|
||||
/>
|
||||
) : (
|
||||
<TextareaNew
|
||||
name="value"
|
||||
rows={3}
|
||||
value={stringValue}
|
||||
disabled={stringValue === undefined}
|
||||
readOnly={readonly}
|
||||
embedded
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</container>
|
||||
);
|
||||
});
|
||||
|
||||
+32
-1
@@ -6,15 +6,21 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Bootstrap, injectable } from '@cloudbeaver/core-di';
|
||||
import { ResultDataFormat } from '@cloudbeaver/core-sdk';
|
||||
|
||||
import { DataValuePanelService } from '../../TableViewer/ValuePanel/DataValuePanelService';
|
||||
import { TextValuePresentation } from './TextValuePresentation';
|
||||
import { TextValuePresentationService } from './TextValuePresentationService';
|
||||
|
||||
@injectable()
|
||||
export class TextValuePresentationBootstrap extends Bootstrap {
|
||||
constructor(private readonly dataValuePanelService: DataValuePanelService) {
|
||||
constructor(
|
||||
private readonly textValuePresentationService: TextValuePresentationService,
|
||||
private readonly dataValuePanelService: DataValuePanelService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -26,6 +32,31 @@ export class TextValuePresentationBootstrap extends Bootstrap {
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
panel: () => TextValuePresentation,
|
||||
});
|
||||
|
||||
this.textValuePresentationService.add({
|
||||
key: 'text/plain',
|
||||
name: 'data_viewer_presentation_value_text_plain_title',
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
panel: () => React.Fragment,
|
||||
});
|
||||
this.textValuePresentationService.add({
|
||||
key: 'text/html',
|
||||
name: 'data_viewer_presentation_value_text_html_title',
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
panel: () => React.Fragment,
|
||||
});
|
||||
this.textValuePresentationService.add({
|
||||
key: 'text/xml',
|
||||
name: 'data_viewer_presentation_value_text_xml_title',
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
panel: () => React.Fragment,
|
||||
});
|
||||
this.textValuePresentationService.add({
|
||||
key: 'application/json',
|
||||
name: 'data_viewer_presentation_value_text_json_title',
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
panel: () => React.Fragment,
|
||||
});
|
||||
}
|
||||
|
||||
load(): void { }
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2021 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 { ITabInfo, ITabInfoOptions, TabsContainer } from '@cloudbeaver/core-blocks';
|
||||
import { injectable } from '@cloudbeaver/core-di';
|
||||
|
||||
@injectable()
|
||||
export class TextValuePresentationService {
|
||||
readonly tabs: TabsContainer;
|
||||
|
||||
constructor() {
|
||||
this.tabs = new TabsContainer();
|
||||
}
|
||||
|
||||
get(tabId: string): ITabInfo | undefined {
|
||||
return this.tabs.getTabInfo(tabId);
|
||||
}
|
||||
|
||||
add(tabInfo: ITabInfoOptions): void {
|
||||
this.tabs.add(tabInfo);
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,9 @@ export default [
|
||||
['data_viewer_statistics_updated_rows', 'Updated Rows:'],
|
||||
['data_viewer_presentation_value_title', 'Value'],
|
||||
['data_viewer_presentation_value_text_title', 'Text'],
|
||||
['data_viewer_presentation_value_text_plain_title', 'Text'],
|
||||
['data_viewer_presentation_value_text_html_title', 'HTML'],
|
||||
['data_viewer_presentation_value_text_xml_title', 'XML'],
|
||||
['data_viewer_presentation_value_text_json_title', 'JSON'],
|
||||
['data_viewer_presentation_value_image_title', 'Image'],
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DataValuePanelBootstrap } from './TableViewer/ValuePanel/DataValuePanel
|
||||
import { DataValuePanelService } from './TableViewer/ValuePanel/DataValuePanelService';
|
||||
import { ImageValuePresentationBootstrap } from './ValuePanelPresentation/ImageValue/ImageValuePresentationBootstrap';
|
||||
import { TextValuePresentationBootstrap } from './ValuePanelPresentation/TextValue/TextValuePresentationBootstrap';
|
||||
import { TextValuePresentationService } from './ValuePanelPresentation/TextValue/TextValuePresentationService';
|
||||
|
||||
export const manifest: PluginManifest = {
|
||||
info: {
|
||||
@@ -36,6 +37,7 @@ export const manifest: PluginManifest = {
|
||||
TableHeaderService,
|
||||
LocaleService,
|
||||
DataValuePanelService,
|
||||
TextValuePresentationService,
|
||||
TextValuePresentationBootstrap,
|
||||
ImageValuePresentationBootstrap,
|
||||
DataValuePanelBootstrap,
|
||||
|
||||
Reference in New Issue
Block a user