mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[data view field editor] Allow editing of DataViewLazy (#186348)
## Summary Data view field editor will now allow editing of fields when provided with a DataViewLazy object. Previously it required a DataView object. This change makes it easier for API consumers to move from DataView to DataViewLazy usage. Internally the data view field editor still uses DataView objects since some of the validation code expects a complete field list. The validation code would need to be rewritten to assume incompete field lists. There is the potential for a performance hit when loading a large field list. After the initial load it will be loaded from the browser cache which should be performant. Part of https://github.com/elastic/kibana/issues/178926
This commit is contained in:
parent
772ace62d7
commit
74a202a79a
16 changed files with 46 additions and 39 deletions
|
@ -650,10 +650,10 @@ export const UnifiedDataTable = ({
|
||||||
const editField = useMemo(
|
const editField = useMemo(
|
||||||
() =>
|
() =>
|
||||||
onFieldEdited
|
onFieldEdited
|
||||||
? (fieldName: string) => {
|
? async (fieldName: string) => {
|
||||||
closeFieldEditor.current =
|
closeFieldEditor.current =
|
||||||
onFieldEdited &&
|
onFieldEdited &&
|
||||||
services?.dataViewFieldEditor?.openEditor({
|
(await services?.dataViewFieldEditor?.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView,
|
dataView,
|
||||||
},
|
},
|
||||||
|
@ -661,7 +661,7 @@ export const UnifiedDataTable = ({
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
await onFieldEdited();
|
await onFieldEdited();
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
[dataView, onFieldEdited, services?.dataViewFieldEditor]
|
[dataView, onFieldEdited, services?.dataViewFieldEditor]
|
||||||
|
|
|
@ -163,8 +163,8 @@ const UnifiedFieldListSidebarContainer = memo(
|
||||||
const editField = useMemo(
|
const editField = useMemo(
|
||||||
() =>
|
() =>
|
||||||
dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
|
dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
|
||||||
? (fieldName?: string) => {
|
? async (fieldName?: string) => {
|
||||||
const ref = dataViewFieldEditor.openEditor({
|
const ref = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView,
|
dataView,
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,13 +15,14 @@ import { euiFlyoutClassname } from './constants';
|
||||||
import type { ApiService } from './lib/api';
|
import type { ApiService } from './lib/api';
|
||||||
import type {
|
import type {
|
||||||
DataPublicPluginStart,
|
DataPublicPluginStart,
|
||||||
DataView,
|
|
||||||
UsageCollectionStart,
|
UsageCollectionStart,
|
||||||
RuntimeType,
|
RuntimeType,
|
||||||
DataViewsPublicPluginStart,
|
DataViewsPublicPluginStart,
|
||||||
FieldFormatsStart,
|
FieldFormatsStart,
|
||||||
DataViewField,
|
DataViewField,
|
||||||
|
DataViewLazy,
|
||||||
} from './shared_imports';
|
} from './shared_imports';
|
||||||
|
import { DataView } from './shared_imports';
|
||||||
import { createKibanaReactContext } from './shared_imports';
|
import { createKibanaReactContext } from './shared_imports';
|
||||||
import type { CloseEditor, Field, InternalFieldType, PluginStart } from './types';
|
import type { CloseEditor, Field, InternalFieldType, PluginStart } from './types';
|
||||||
|
|
||||||
|
@ -34,7 +35,7 @@ export interface OpenFieldEditorOptions {
|
||||||
* context containing the data view the field belongs to
|
* context containing the data view the field belongs to
|
||||||
*/
|
*/
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: DataView;
|
dataView: DataView | DataViewLazy;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* action to take after field is saved
|
* action to take after field is saved
|
||||||
|
@ -72,7 +73,7 @@ export const getFieldEditorOpener =
|
||||||
usageCollection,
|
usageCollection,
|
||||||
apiService,
|
apiService,
|
||||||
}: Dependencies) =>
|
}: Dependencies) =>
|
||||||
(options: OpenFieldEditorOptions): CloseEditor => {
|
async (options: OpenFieldEditorOptions): Promise<CloseEditor> => {
|
||||||
const { uiSettings, overlays, docLinks, notifications, settings, theme } = core;
|
const { uiSettings, overlays, docLinks, notifications, settings, theme } = core;
|
||||||
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
|
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
|
||||||
uiSettings,
|
uiSettings,
|
||||||
|
@ -91,12 +92,12 @@ export const getFieldEditorOpener =
|
||||||
canCloseValidator.current = args.canCloseValidator;
|
canCloseValidator.current = args.canCloseValidator;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditor = ({
|
const openEditor = async ({
|
||||||
onSave,
|
onSave,
|
||||||
fieldName: fieldNameToEdit,
|
fieldName: fieldNameToEdit,
|
||||||
fieldToCreate,
|
fieldToCreate,
|
||||||
ctx: { dataView },
|
ctx: { dataView: dataViewLazyOrNot },
|
||||||
}: OpenFieldEditorOptions): CloseEditor => {
|
}: OpenFieldEditorOptions): Promise<CloseEditor> => {
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
if (overlayRef) {
|
if (overlayRef) {
|
||||||
overlayRef.close();
|
overlayRef.close();
|
||||||
|
@ -113,7 +114,7 @@ export const getFieldEditorOpener =
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRuntimeField = (name: string) => {
|
const getRuntimeField = (name: string) => {
|
||||||
const fld = dataView.getAllRuntimeFields()[name];
|
const fld = dataViewLazyOrNot.getAllRuntimeFields()[name];
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
runtimeField: fld,
|
runtimeField: fld,
|
||||||
|
@ -129,6 +130,11 @@ export const getFieldEditorOpener =
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dataView =
|
||||||
|
dataViewLazyOrNot instanceof DataView
|
||||||
|
? dataViewLazyOrNot
|
||||||
|
: await dataViews.toDataView(dataViewLazyOrNot);
|
||||||
|
|
||||||
const dataViewField = fieldNameToEdit
|
const dataViewField = fieldNameToEdit
|
||||||
? dataView.getFieldByName(fieldNameToEdit) || getRuntimeField(fieldNameToEdit)
|
? dataView.getFieldByName(fieldNameToEdit) || getRuntimeField(fieldNameToEdit)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
|
@ -63,7 +63,7 @@ describe('DataViewFieldEditorPlugin', () => {
|
||||||
};
|
};
|
||||||
const { openEditor } = plugin.start(coreStartMocked, pluginStart);
|
const { openEditor } = plugin.start(coreStartMocked, pluginStart);
|
||||||
|
|
||||||
openEditor({ onSave: onSaveSpy, ctx: { dataView: {} as any } });
|
await openEditor({ onSave: onSaveSpy, ctx: { dataView: {} as any } });
|
||||||
|
|
||||||
expect(openFlyout).toHaveBeenCalled();
|
expect(openFlyout).toHaveBeenCalled();
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ describe('DataViewFieldEditorPlugin', () => {
|
||||||
test('should return a handler to close the flyout', async () => {
|
test('should return a handler to close the flyout', async () => {
|
||||||
const { openEditor } = plugin.start(coreStart, pluginStart);
|
const { openEditor } = plugin.start(coreStart, pluginStart);
|
||||||
|
|
||||||
const closeEditorHandler = openEditor({ onSave: noop, ctx: { dataView: {} as any } });
|
const closeEditorHandler = await openEditor({ onSave: noop, ctx: { dataView: {} as any } });
|
||||||
expect(typeof closeEditorHandler).toBe('function');
|
expect(typeof closeEditorHandler).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,11 +8,8 @@
|
||||||
|
|
||||||
export type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
export type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||||
|
|
||||||
export type {
|
export type { DataViewsPublicPluginStart, DataViewField } from '@kbn/data-views-plugin/public';
|
||||||
DataViewsPublicPluginStart,
|
export { DataView } from '@kbn/data-views-plugin/public';
|
||||||
DataView,
|
|
||||||
DataViewField,
|
|
||||||
} from '@kbn/data-views-plugin/public';
|
|
||||||
export type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
export type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||||
|
|
||||||
export type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
export type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||||
|
@ -24,6 +21,7 @@ export type {
|
||||||
RuntimeFieldSubField,
|
RuntimeFieldSubField,
|
||||||
RuntimeFieldSubFields,
|
RuntimeFieldSubFields,
|
||||||
RuntimePrimitiveTypes,
|
RuntimePrimitiveTypes,
|
||||||
|
DataViewLazy,
|
||||||
} from '@kbn/data-views-plugin/common';
|
} from '@kbn/data-views-plugin/common';
|
||||||
export { KBN_FIELD_TYPES, ES_FIELD_TYPES } from '@kbn/data-plugin/common';
|
export { KBN_FIELD_TYPES, ES_FIELD_TYPES } from '@kbn/data-plugin/common';
|
||||||
|
|
||||||
|
|
|
@ -38,12 +38,12 @@ export interface PluginStart {
|
||||||
/**
|
/**
|
||||||
* Method to open the data view field editor fly-out
|
* Method to open the data view field editor fly-out
|
||||||
*/
|
*/
|
||||||
openEditor(options: OpenFieldEditorOptions): () => void;
|
openEditor(options: OpenFieldEditorOptions): Promise<CloseEditor>;
|
||||||
/**
|
/**
|
||||||
* Method to open the data view field delete fly-out
|
* Method to open the data view field delete fly-out
|
||||||
* @param options Configuration options for the fly-out
|
* @param options Configuration options for the fly-out
|
||||||
*/
|
*/
|
||||||
openDeleteModal(options: OpenFieldDeleteModalOptions): () => void;
|
openDeleteModal(options: OpenFieldDeleteModalOptions): CloseEditor;
|
||||||
fieldFormatEditors: FormatEditorServiceStart['fieldFormatEditors'];
|
fieldFormatEditors: FormatEditorServiceStart['fieldFormatEditors'];
|
||||||
/**
|
/**
|
||||||
* Convenience method for user permissions checks
|
* Convenience method for user permissions checks
|
||||||
|
|
|
@ -308,8 +308,8 @@ export const Tabs: React.FC<TabsProps> = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openFieldEditor = useCallback(
|
const openFieldEditor = useCallback(
|
||||||
(fieldName?: string) => {
|
async (fieldName?: string) => {
|
||||||
closeEditorHandler.current = dataViewFieldEditor.openEditor({
|
closeEditorHandler.current = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: indexPattern,
|
dataView: indexPattern,
|
||||||
},
|
},
|
||||||
|
|
|
@ -40,6 +40,7 @@ const createStartContract = (): Start => {
|
||||||
getIdsWithTitle: jest.fn(),
|
getIdsWithTitle: jest.fn(),
|
||||||
getFieldsForIndexPattern: jest.fn(),
|
getFieldsForIndexPattern: jest.fn(),
|
||||||
create: jest.fn().mockReturnValue(Promise.resolve({})),
|
create: jest.fn().mockReturnValue(Promise.resolve({})),
|
||||||
|
toDataView: jest.fn().mockReturnValue(Promise.resolve({})),
|
||||||
} as unknown as jest.Mocked<DataViewsContract>;
|
} as unknown as jest.Mocked<DataViewsContract>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -92,7 +92,7 @@ export const DiscoverTopNav = ({
|
||||||
? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => {
|
? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => {
|
||||||
if (dataView?.id) {
|
if (dataView?.id) {
|
||||||
const dataViewInstance = await data.dataViews.get(dataView.id);
|
const dataViewInstance = await data.dataViews.get(dataView.id);
|
||||||
closeFieldEditor.current = dataViewFieldEditor.openEditor({
|
closeFieldEditor.current = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: dataViewInstance,
|
dataView: dataViewInstance,
|
||||||
},
|
},
|
||||||
|
|
|
@ -123,8 +123,8 @@ export function getActions(
|
||||||
),
|
),
|
||||||
type: 'icon',
|
type: 'icon',
|
||||||
icon: 'indexEdit',
|
icon: 'indexEdit',
|
||||||
onClick: (item: FieldVisConfig) => {
|
onClick: async (item: FieldVisConfig) => {
|
||||||
dataViewEditorRef.current = services.dataViewFieldEditor?.openEditor({
|
dataViewEditorRef.current = await services.dataViewFieldEditor?.openEditor({
|
||||||
ctx: { dataView },
|
ctx: { dataView },
|
||||||
fieldName: item.fieldName,
|
fieldName: item.fieldName,
|
||||||
onSave: refreshPage,
|
onSave: refreshPage,
|
||||||
|
|
|
@ -48,8 +48,8 @@ export function DataVisualizerDataViewManagement(props: DataVisualizerDataViewMa
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addField = () => {
|
const addField = async () => {
|
||||||
closeFieldEditor.current = dataViewFieldEditor.openEditor({
|
closeFieldEditor.current = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: currentDataView,
|
dataView: currentDataView,
|
||||||
},
|
},
|
||||||
|
|
|
@ -913,7 +913,7 @@ export const LensTopNavMenu = ({
|
||||||
? async (fieldName?: string, _uiAction: 'edit' | 'add' = 'edit') => {
|
? async (fieldName?: string, _uiAction: 'edit' | 'add' = 'edit') => {
|
||||||
if (currentIndexPattern?.id) {
|
if (currentIndexPattern?.id) {
|
||||||
const indexPatternInstance = await data.dataViews.get(currentIndexPattern?.id);
|
const indexPatternInstance = await data.dataViews.get(currentIndexPattern?.id);
|
||||||
closeFieldEditor.current = dataViewFieldEditor.openEditor({
|
closeFieldEditor.current = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: indexPatternInstance,
|
dataView: indexPatternInstance,
|
||||||
},
|
},
|
||||||
|
|
|
@ -307,7 +307,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
|
||||||
editPermission
|
editPermission
|
||||||
? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => {
|
? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => {
|
||||||
const indexPatternInstance = await dataViews.get(currentIndexPattern?.id);
|
const indexPatternInstance = await dataViews.get(currentIndexPattern?.id);
|
||||||
closeFieldEditor.current = indexPatternFieldEditor.openEditor({
|
closeFieldEditor.current = await indexPatternFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: indexPatternInstance,
|
dataView: indexPatternInstance,
|
||||||
},
|
},
|
||||||
|
|
|
@ -38,8 +38,8 @@ export const DataViewPicker = memo(() => {
|
||||||
|
|
||||||
const { dataViewId } = useSelector(sourcererAdapterSelector);
|
const { dataViewId } = useSelector(sourcererAdapterSelector);
|
||||||
|
|
||||||
const createNewDataView = useCallback(() => {
|
const createNewDataView = useCallback(async () => {
|
||||||
closeDataViewEditor.current = dataViewEditor.openEditor({
|
closeDataViewEditor.current = await dataViewEditor.openEditor({
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
onSave: () => console.log('new data view saved'),
|
onSave: () => console.log('new data view saved'),
|
||||||
allowAdHocDataView: true,
|
allowAdHocDataView: true,
|
||||||
|
@ -58,7 +58,7 @@ export const DataViewPicker = memo(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataViewInstance = await data.dataViews.get(dataViewId);
|
const dataViewInstance = await data.dataViews.get(dataViewId);
|
||||||
closeFieldEditor.current = dataViewFieldEditor.openEditor({
|
closeFieldEditor.current = await dataViewFieldEditor.openEditor({
|
||||||
ctx: {
|
ctx: {
|
||||||
dataView: dataViewInstance,
|
dataView: dataViewInstance,
|
||||||
},
|
},
|
||||||
|
|
|
@ -164,7 +164,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
it('should dispatch the proper action when a new field is saved', async () => {
|
it('should dispatch the proper action when a new field is saved', async () => {
|
||||||
let onSave: ((field: DataViewField[]) => void) | undefined;
|
let onSave: ((field: DataViewField[]) => void) | undefined;
|
||||||
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
||||||
useKibanaMock().services.dataViewFieldEditor.openEditor = (options) => {
|
useKibanaMock().services.dataViewFieldEditor.openEditor = async (options) => {
|
||||||
onSave = options.onSave;
|
onSave = options.onSave;
|
||||||
return () => {};
|
return () => {};
|
||||||
};
|
};
|
||||||
|
@ -198,7 +198,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
it('should dispatch the proper actions when a field is edited', async () => {
|
it('should dispatch the proper actions when a field is edited', async () => {
|
||||||
let onSave: ((field: DataViewField[]) => void) | undefined;
|
let onSave: ((field: DataViewField[]) => void) | undefined;
|
||||||
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
||||||
useKibanaMock().services.dataViewFieldEditor.openEditor = (options) => {
|
useKibanaMock().services.dataViewFieldEditor.openEditor = async (options) => {
|
||||||
onSave = options.onSave;
|
onSave = options.onSave;
|
||||||
return () => {};
|
return () => {};
|
||||||
};
|
};
|
||||||
|
@ -266,7 +266,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
it("should store 'closeEditor' in the actions ref when editor is open by create button", async () => {
|
it("should store 'closeEditor' in the actions ref when editor is open by create button", async () => {
|
||||||
const mockCloseEditor = jest.fn();
|
const mockCloseEditor = jest.fn();
|
||||||
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
||||||
useKibanaMock().services.dataViewFieldEditor.openEditor = () => mockCloseEditor;
|
useKibanaMock().services.dataViewFieldEditor.openEditor = async () => mockCloseEditor;
|
||||||
|
|
||||||
const editorActionsRef: FieldEditorActionsRef = React.createRef();
|
const editorActionsRef: FieldEditorActionsRef = React.createRef();
|
||||||
|
|
||||||
|
@ -280,6 +280,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
expect(editorActionsRef?.current).toBeNull();
|
expect(editorActionsRef?.current).toBeNull();
|
||||||
|
|
||||||
getByRole('button').click();
|
getByRole('button').click();
|
||||||
|
await runAllPromises();
|
||||||
|
|
||||||
expect(mockCloseEditor).not.toHaveBeenCalled();
|
expect(mockCloseEditor).not.toHaveBeenCalled();
|
||||||
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
|
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
|
||||||
|
@ -293,7 +294,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
it("should store 'closeEditor' in the actions ref when editor is open by edit button", async () => {
|
it("should store 'closeEditor' in the actions ref when editor is open by edit button", async () => {
|
||||||
const mockCloseEditor = jest.fn();
|
const mockCloseEditor = jest.fn();
|
||||||
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
useKibanaMock().services.data.dataViews.get = () => Promise.resolve({} as DataView);
|
||||||
useKibanaMock().services.dataViewFieldEditor.openEditor = () => mockCloseEditor;
|
useKibanaMock().services.dataViewFieldEditor.openEditor = async () => mockCloseEditor;
|
||||||
|
|
||||||
const editorActionsRef: FieldEditorActionsRef = React.createRef();
|
const editorActionsRef: FieldEditorActionsRef = React.createRef();
|
||||||
|
|
||||||
|
@ -311,6 +312,7 @@ describe('useFieldBrowserOptions', () => {
|
||||||
expect(editorActionsRef?.current).toBeNull();
|
expect(editorActionsRef?.current).toBeNull();
|
||||||
|
|
||||||
getByTestId('actionEditRuntimeField').click();
|
getByTestId('actionEditRuntimeField').click();
|
||||||
|
await runAllPromises();
|
||||||
|
|
||||||
expect(mockCloseEditor).not.toHaveBeenCalled();
|
expect(mockCloseEditor).not.toHaveBeenCalled();
|
||||||
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
|
expect(editorActionsRef?.current?.closeEditor).toBeDefined();
|
||||||
|
|
|
@ -81,9 +81,9 @@ export const useFieldBrowserOptions: UseFieldBrowserOptions = ({
|
||||||
}, [selectedDataViewId, missingPatterns, dataViews]);
|
}, [selectedDataViewId, missingPatterns, dataViews]);
|
||||||
|
|
||||||
const openFieldEditor = useCallback<OpenFieldEditor>(
|
const openFieldEditor = useCallback<OpenFieldEditor>(
|
||||||
(fieldName) => {
|
async (fieldName) => {
|
||||||
if (dataView && selectedDataViewId) {
|
if (dataView && selectedDataViewId) {
|
||||||
const closeFieldEditor = dataViewFieldEditor.openEditor({
|
const closeFieldEditor = await dataViewFieldEditor.openEditor({
|
||||||
ctx: { dataView },
|
ctx: { dataView },
|
||||||
fieldName,
|
fieldName,
|
||||||
onSave: async (savedFields: DataViewField[]) => {
|
onSave: async (savedFields: DataViewField[]) => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue