Transformations: feature toggle cleanup transformations redesign (#113561)
* remove feature toggle transformationsRedesign * remove for event tracking * remove uneccessary code
This commit is contained in:
@@ -34,7 +34,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger | Yes |
|
||||
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes |
|
||||
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes |
|
||||
| `transformationsRedesign` | Enables the transformations redesign | Yes |
|
||||
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
|
||||
| `dashgpt` | Enable AI powered features in dashboards | Yes |
|
||||
| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | Yes |
|
||||
|
||||
@@ -161,11 +161,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
awsDatasourcesTempCredentials?: boolean;
|
||||
/**
|
||||
* Enables the transformations redesign
|
||||
* @default true
|
||||
*/
|
||||
transformationsRedesign?: boolean;
|
||||
/**
|
||||
* Enable support for Machine Learning in server-side expressions
|
||||
*/
|
||||
mlExpressions?: boolean;
|
||||
|
||||
@@ -255,15 +255,6 @@ var (
|
||||
Expression: "true", // enabled by default
|
||||
Owner: awsDatasourcesSquad,
|
||||
},
|
||||
{
|
||||
Name: "transformationsRedesign",
|
||||
Description: "Enables the transformations redesign",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Expression: "true", // enabled by default
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
AllowSelfServe: true,
|
||||
},
|
||||
{
|
||||
Name: "mlExpressions",
|
||||
Description: "Enable support for Machine Learning in server-side expressions",
|
||||
|
||||
Generated
-1
@@ -32,7 +32,6 @@ enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true
|
||||
extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true
|
||||
awsDatasourcesTempCredentials,GA,@grafana/aws-datasources,false,false,false
|
||||
transformationsRedesign,GA,@grafana/observability-metrics,false,false,true
|
||||
mlExpressions,experimental,@grafana/alerting-squad,false,false,false
|
||||
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
|
||||
|
||||
|
Generated
-4
@@ -139,10 +139,6 @@ const (
|
||||
// Support temporary security credentials in AWS plugins for Grafana Cloud customers
|
||||
FlagAwsDatasourcesTempCredentials = "awsDatasourcesTempCredentials"
|
||||
|
||||
// FlagTransformationsRedesign
|
||||
// Enables the transformations redesign
|
||||
FlagTransformationsRedesign = "transformationsRedesign"
|
||||
|
||||
// FlagMlExpressions
|
||||
// Enable support for Machine Learning in server-side expressions
|
||||
FlagMlExpressions = "mlExpressions"
|
||||
|
||||
+2
-1
@@ -4147,7 +4147,8 @@
|
||||
"metadata": {
|
||||
"name": "transformationsRedesign",
|
||||
"resourceVersion": "1753448760331",
|
||||
"creationTimestamp": "2023-07-12T16:35:49Z"
|
||||
"creationTimestamp": "2023-07-12T16:35:49Z",
|
||||
"deletionTimestamp": "2025-11-06T20:37:15Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the transformations redesign",
|
||||
|
||||
@@ -29,10 +29,7 @@ export const PanelEditorTabs = memo(({ panel, dashboard, tabs, onChangeTab }: Pa
|
||||
|
||||
const instrumentedOnChangeTab = useCallback(
|
||||
(tab: PanelEditorTab) => {
|
||||
let eventName = 'panel_editor_tabs_changed';
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
eventName = 'transformations_redesign_' + eventName;
|
||||
}
|
||||
let eventName = 'transformations_redesign_panel_editor_tabs_changed';
|
||||
|
||||
if (!tab.active) {
|
||||
reportInteraction(eventName, { tab_id: tab.id });
|
||||
|
||||
+18
-24
@@ -20,7 +20,6 @@ import {
|
||||
QueryOperationToggleAction,
|
||||
} from 'app/core/components/QueryOperationRow/QueryOperationAction';
|
||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||
import config from 'app/core/config';
|
||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
||||
|
||||
import { TransformationEditor } from './TransformationEditor';
|
||||
@@ -89,10 +88,7 @@ export const TransformationOperationRow = ({
|
||||
const instrumentToggleCallback = useCallback(
|
||||
(callback: (e: React.MouseEvent) => void, toggleId: string, active: boolean | undefined) =>
|
||||
(e: React.MouseEvent) => {
|
||||
let eventName = 'panel_editor_tabs_transformations_toggle';
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
eventName = 'transformations_redesign_' + eventName;
|
||||
}
|
||||
let eventName = 'transformations_redesign_panel_editor_tabs_transformations_toggle';
|
||||
|
||||
reportInteraction(eventName, {
|
||||
action: active ? 'off' : 'on',
|
||||
@@ -198,27 +194,25 @@ export const TransformationOperationRow = ({
|
||||
<QueryOperationAction
|
||||
title={t('dashboard.transformation-operation-row.render-actions.title-remove', 'Remove')}
|
||||
icon="trash-alt"
|
||||
onClick={() => (config.featureToggles.transformationsRedesign ? setShowDeleteModal(true) : onRemove(index))}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
/>
|
||||
|
||||
{config.featureToggles.transformationsRedesign && (
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title={t('dashboard.transformation-operation-row.title-delete', 'Delete {{name}}?', {
|
||||
name: uiConfig.name,
|
||||
})}
|
||||
body={t(
|
||||
'dashboard.transformation-operation-row.body-delete',
|
||||
'Note that removing one transformation may break others. If there is only a single transformation, you will go back to the main selection screen.'
|
||||
)}
|
||||
confirmText={t('dashboard.transformation-operation-row.render-actions.confirmText-delete', 'Delete')}
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false);
|
||||
onRemove(index);
|
||||
}}
|
||||
onDismiss={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title={t('dashboard.transformation-operation-row.title-delete', 'Delete {{name}}?', {
|
||||
name: uiConfig.name,
|
||||
})}
|
||||
body={t(
|
||||
'dashboard.transformation-operation-row.body-delete',
|
||||
'Note that removing one transformation may break others. If there is only a single transformation, you will go back to the main selection screen.'
|
||||
)}
|
||||
confirmText={t('dashboard.transformation-operation-row.render-actions.confirmText-delete', 'Delete')}
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false);
|
||||
onRemove(index);
|
||||
}}
|
||||
onDismiss={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-34
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import config from 'app/core/config';
|
||||
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
|
||||
|
||||
import { PanelModel } from '../../state/PanelModel';
|
||||
@@ -20,23 +19,15 @@ describe('TransformationsEditor', () => {
|
||||
standardTransformersRegistry.setInit(getStandardTransformers);
|
||||
|
||||
describe('when no transformations configured', () => {
|
||||
it('renders transformation list by default and without transformationsRedesign on', () => {
|
||||
setup();
|
||||
const cards = screen.getAllByTestId(/New transform/i);
|
||||
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
|
||||
});
|
||||
|
||||
it('renders transformation empty message with transformationsRedesign feature toggled on', () => {
|
||||
config.featureToggles.transformationsRedesign = true;
|
||||
it('renders transformation empty message', () => {
|
||||
setup();
|
||||
const message = screen.getAllByTestId('data-testid no transformations message');
|
||||
expect(message.length).toEqual(1);
|
||||
config.featureToggles.transformationsRedesign = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when transformations configured', () => {
|
||||
function renderEditors() {
|
||||
it('renders transformation editors', () => {
|
||||
setup([
|
||||
{
|
||||
id: 'reduce',
|
||||
@@ -45,18 +36,11 @@ describe('TransformationsEditor', () => {
|
||||
]);
|
||||
const editors = screen.getAllByTestId(/Transformation editor/);
|
||||
expect(editors).toHaveLength(1);
|
||||
}
|
||||
|
||||
it('renders transformation editors', renderEditors);
|
||||
it('renders transformation editors with transformationsRedesign feature toggled on', () => {
|
||||
config.featureToggles.transformationsRedesign = true;
|
||||
renderEditors();
|
||||
config.featureToggles.transformationsRedesign = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when Add transformation clicked', () => {
|
||||
async function renderPicker() {
|
||||
it('renders transformations picker', async () => {
|
||||
setup([
|
||||
{
|
||||
id: 'reduce',
|
||||
@@ -69,19 +53,12 @@ describe('TransformationsEditor', () => {
|
||||
|
||||
const search = screen.getByTestId(selectors.components.Transforms.searchInput);
|
||||
expect(search).toBeDefined();
|
||||
}
|
||||
|
||||
it('renders transformations picker', renderPicker);
|
||||
it('renders transformation picker with transformationsRedesign feature toggled on', async () => {
|
||||
config.featureToggles.transformationsRedesign = true;
|
||||
await renderPicker();
|
||||
config.featureToggles.transformationsRedesign = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
describe('debug', () => {
|
||||
async function showHideDebugger() {
|
||||
it('should show/hide debugger', async () => {
|
||||
setup([
|
||||
{
|
||||
id: 'reduce',
|
||||
@@ -96,13 +73,6 @@ describe('TransformationsEditor', () => {
|
||||
await userEvent.click(debugButton);
|
||||
|
||||
expect(screen.getByTestId(debuggerSelector)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
it('should show/hide debugger', showHideDebugger);
|
||||
it('renders transformation editors with transformationsRedesign feature toggled on', async () => {
|
||||
config.featureToggles.transformationsRedesign = true;
|
||||
await showHideDebugger();
|
||||
config.featureToggles.transformationsRedesign = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+60
-113
@@ -25,14 +25,12 @@ import {
|
||||
ButtonGroup,
|
||||
ScrollContainer,
|
||||
} from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { EmptyTransformationsMessage } from 'app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage';
|
||||
|
||||
import { PanelModel } from '../../state/PanelModel';
|
||||
import { PanelNotSupported } from '../PanelEditor/PanelNotSupported';
|
||||
|
||||
import { TransformationOperationRows } from './TransformationOperationRows';
|
||||
import { TransformationPicker } from './TransformationPicker';
|
||||
import { TransformationPickerNg } from './TransformationPickerNg';
|
||||
import { TransformationsEditorTransformation } from './types';
|
||||
|
||||
@@ -141,21 +139,19 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<TransformationsEditorProps>, prevState: Readonly<State>): void {
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
const prevHasTransforms = prevState.transformations.length > 0;
|
||||
const prevShowPicker = !prevHasTransforms || prevState.showPicker;
|
||||
const prevHasTransforms = prevState.transformations.length > 0;
|
||||
const prevShowPicker = !prevHasTransforms || prevState.showPicker;
|
||||
|
||||
const currentHasTransforms = this.state.transformations.length > 0;
|
||||
const currentShowPicker = !currentHasTransforms || this.state.showPicker;
|
||||
const currentHasTransforms = this.state.transformations.length > 0;
|
||||
const currentShowPicker = !currentHasTransforms || this.state.showPicker;
|
||||
|
||||
if (prevShowPicker !== currentShowPicker) {
|
||||
// kindOfZero will be a random number between 0 and 0.5. It will be rounded to 0 by the scrollable component.
|
||||
// We cannot always use 0 as it will not trigger a rerender of the scrollable component consistently
|
||||
// due to React changes detection algo.
|
||||
const kindOfZero = Math.random() / 2;
|
||||
if (prevShowPicker !== currentShowPicker) {
|
||||
// kindOfZero will be a random number between 0 and 0.5. It will be rounded to 0 by the scrollable component.
|
||||
// We cannot always use 0 as it will not trigger a rerender of the scrollable component consistently
|
||||
// due to React changes detection algo.
|
||||
const kindOfZero = Math.random() / 2;
|
||||
|
||||
this.setState({ scrollTop: currentShowPicker ? kindOfZero : Number.MAX_SAFE_INTEGER });
|
||||
}
|
||||
this.setState({ scrollTop: currentShowPicker ? kindOfZero : Number.MAX_SAFE_INTEGER });
|
||||
}
|
||||
|
||||
if (prevState.scrollTop !== this.state.scrollTop) {
|
||||
@@ -183,10 +179,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
};
|
||||
|
||||
onTransformationAdd = (selectable: SelectableValue<string>) => {
|
||||
let eventName = 'panel_editor_tabs_transformations_management';
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
eventName = 'transformations_redesign_' + eventName;
|
||||
}
|
||||
const eventName = 'transformations_redesign_panel_editor_tabs_transformations_management';
|
||||
|
||||
reportInteraction(eventName, {
|
||||
action: 'add',
|
||||
@@ -211,10 +204,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
onTransformationChange = (idx: number, dataConfig: DataTransformerConfig) => {
|
||||
const { transformations } = this.state;
|
||||
const next = Array.from(transformations);
|
||||
let eventName = 'panel_editor_tabs_transformations_management';
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
eventName = 'transformations_redesign_' + eventName;
|
||||
}
|
||||
const eventName = 'transformations_redesign_panel_editor_tabs_transformations_management';
|
||||
|
||||
reportInteraction(eventName, {
|
||||
action: 'change',
|
||||
@@ -227,10 +217,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
onTransformationRemove = (idx: number) => {
|
||||
const { transformations } = this.state;
|
||||
const next = Array.from(transformations);
|
||||
let eventName = 'panel_editor_tabs_transformations_management';
|
||||
if (config.featureToggles.transformationsRedesign) {
|
||||
eventName = 'transformations_redesign_' + eventName;
|
||||
}
|
||||
const eventName = 'transformations_redesign_panel_editor_tabs_transformations_management';
|
||||
|
||||
reportInteraction(eventName, {
|
||||
action: 'remove',
|
||||
@@ -298,21 +285,12 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
};
|
||||
|
||||
renderTransformsPicker() {
|
||||
let { showPicker } = this.state;
|
||||
const { transformations, search } = this.state;
|
||||
const { transformationsRedesign } = config.featureToggles;
|
||||
const { showPicker, transformations, search } = this.state;
|
||||
const noTransforms = !transformations?.length;
|
||||
const hasTransforms = transformations.length > 0;
|
||||
let suffix: React.ReactNode = null;
|
||||
let xforms = standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0));
|
||||
|
||||
// In the case we're not on the transformation
|
||||
// redesign and there are no transformations
|
||||
// then we show the picker in that case
|
||||
if (!transformationsRedesign && noTransforms) {
|
||||
showPicker = true;
|
||||
}
|
||||
|
||||
if (this.state.selectedFilter !== VIEW_ALL_VALUE) {
|
||||
xforms = xforms.filter(
|
||||
(t) =>
|
||||
@@ -357,77 +335,53 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
);
|
||||
}
|
||||
|
||||
// If we're in the transformation redesign
|
||||
// we have the add transformation add the
|
||||
// delete all control
|
||||
let picker = null;
|
||||
let deleteAll = null;
|
||||
if (transformationsRedesign) {
|
||||
picker = (
|
||||
<TransformationPickerNg
|
||||
noTransforms={noTransforms}
|
||||
search={search}
|
||||
suffix={suffix}
|
||||
xforms={xforms}
|
||||
onClose={() => this.setState({ showPicker: false })}
|
||||
onSelectedFilterChange={(filter) => this.setState({ selectedFilter: filter })}
|
||||
onShowIllustrationsChange={(showIllustrations) => this.setState({ showIllustrations })}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onSearchKeyDown={this.onSearchKeyDown}
|
||||
onTransformationAdd={this.onTransformationAdd}
|
||||
data={this.state.data.series}
|
||||
selectedFilter={this.state.selectedFilter}
|
||||
showIllustrations={this.state.showIllustrations}
|
||||
/>
|
||||
);
|
||||
const picker = (
|
||||
<TransformationPickerNg
|
||||
noTransforms={noTransforms}
|
||||
search={search}
|
||||
suffix={suffix}
|
||||
xforms={xforms}
|
||||
onClose={() => this.setState({ showPicker: false })}
|
||||
onSelectedFilterChange={(filter) => this.setState({ selectedFilter: filter })}
|
||||
onShowIllustrationsChange={(showIllustrations) => this.setState({ showIllustrations })}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onSearchKeyDown={this.onSearchKeyDown}
|
||||
onTransformationAdd={this.onTransformationAdd}
|
||||
data={this.state.data.series}
|
||||
selectedFilter={this.state.selectedFilter}
|
||||
showIllustrations={this.state.showIllustrations}
|
||||
/>
|
||||
);
|
||||
|
||||
deleteAll = (
|
||||
<>
|
||||
<Button
|
||||
icon="times"
|
||||
variant="secondary"
|
||||
onClick={() => this.setState({ showRemoveAllModal: true })}
|
||||
style={{ marginLeft: this.props.theme.spacing.md }}
|
||||
>
|
||||
<Trans i18nKey="dashboard.un-themed-transformations-editor.delete-all-transformations">
|
||||
Delete all transformations
|
||||
</Trans>
|
||||
</Button>
|
||||
<ConfirmModal
|
||||
isOpen={Boolean(this.state.showRemoveAllModal)}
|
||||
title={t(
|
||||
'dashboard.un-themed-transformations-editor.title-delete-all-transformations',
|
||||
'Delete all transformations?'
|
||||
)}
|
||||
body={t(
|
||||
'dashboard.un-theme-transformations-editor.body-delete-all-transformations',
|
||||
'By deleting all transformations, you will go back to the main selection screen.'
|
||||
)}
|
||||
confirmText={t('dashboard.un-themed-transformations-editor.confirmText-delete-all', 'Delete all')}
|
||||
onConfirm={() => this.onTransformationRemoveAll()}
|
||||
onDismiss={() => this.setState({ showRemoveAllModal: false })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Otherwise we use the old picker
|
||||
else {
|
||||
picker = (
|
||||
<TransformationPicker
|
||||
noTransforms={noTransforms}
|
||||
search={search}
|
||||
suffix={suffix}
|
||||
xforms={xforms}
|
||||
onSearchChange={this.onSearchChange}
|
||||
onSearchKeyDown={this.onSearchKeyDown}
|
||||
onTransformationAdd={this.onTransformationAdd}
|
||||
const deleteAll = (
|
||||
<>
|
||||
<Button
|
||||
icon="times"
|
||||
variant="secondary"
|
||||
onClick={() => this.setState({ showRemoveAllModal: true })}
|
||||
style={{ marginLeft: this.props.theme.spacing.md }}
|
||||
>
|
||||
<Trans i18nKey="dashboard.un-themed-transformations-editor.delete-all-transformations">
|
||||
Delete all transformations
|
||||
</Trans>
|
||||
</Button>
|
||||
<ConfirmModal
|
||||
isOpen={Boolean(this.state.showRemoveAllModal)}
|
||||
title={t(
|
||||
'dashboard.un-themed-transformations-editor.title-delete-all-transformations',
|
||||
'Delete all transformations?'
|
||||
)}
|
||||
body={t(
|
||||
'dashboard.un-theme-transformations-editor.body-delete-all-transformations',
|
||||
'By deleting all transformations, you will go back to the main selection screen.'
|
||||
)}
|
||||
confirmText={t('dashboard.un-themed-transformations-editor.confirmText-delete-all', 'Delete all')}
|
||||
onConfirm={() => this.onTransformationRemoveAll()}
|
||||
onDismiss={() => this.setState({ showRemoveAllModal: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
// Compose actions, if we're in the
|
||||
// redesign a "Delete All Transformations"
|
||||
// button (with confirm modal) is added
|
||||
const actions = (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
@@ -449,14 +403,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
return (
|
||||
<>
|
||||
{showPicker && picker}
|
||||
{
|
||||
// If the transformation redesign is enabled
|
||||
// and there are transforms then show actions
|
||||
(transformationsRedesign && hasTransforms && actions) ||
|
||||
// If it's not enabled only show actions when there are
|
||||
// transformations and the (old) picker isn't being shown
|
||||
(!transformationsRedesign && !showPicker && hasTransforms && actions)
|
||||
}
|
||||
{hasTransforms && actions}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -481,7 +428,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
<ScrollContainer ref={this.ref} minHeight="100%">
|
||||
<Container padding="lg">
|
||||
<div data-testid={selectors.components.TransformTab.content}>
|
||||
{!hasTransforms && config.featureToggles.transformationsRedesign && this.renderEmptyMessage()}
|
||||
{!hasTransforms && this.renderEmptyMessage()}
|
||||
{hasTransforms && this.renderTransformationEditors()}
|
||||
{this.renderTransformsPicker()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user