Make details visualization configurable (#7535)

- Added possibility to select visible columns and reordering
- Added formatting options as in Table visualization
- Set default alignment to left
This commit is contained in:
Kamil Frydel
2025-10-06 14:10:33 +02:00
committed by GitHub
parent ea589ad477
commit 5b463b0d83
41 changed files with 1758 additions and 352 deletions

View File

@@ -1,64 +0,0 @@
import React, { useState } from "react";
import { map, mapValues, keyBy } from "lodash";
import moment from "moment";
import { RendererPropTypes } from "@/visualizations/prop-types";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import Descriptions from "antd/lib/descriptions";
import Pagination from "antd/lib/pagination";
import "./details.less";
function renderValue(value: any, type: any) {
const formats = {
date: visualizationsSettings.dateFormat,
datetime: visualizationsSettings.dateTimeFormat,
};
if (type === "date" || type === "datetime") {
if (moment.isMoment(value)) {
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
return value.format(formats[type]);
}
}
return "" + value;
}
export default function DetailsRenderer({ data }: any) {
const [page, setPage] = useState(0);
if (!data || !data.rows || data.rows.length === 0) {
return null;
}
const types = mapValues(keyBy(data.columns, "name"), "type");
// We use columsn to maintain order of columns in the view.
const columns = data.columns.map((column: any) => column.name);
const row = data.rows[page];
return (
<div className="details-viz">
<Descriptions size="small" column={1} bordered>
{map(columns, key => (
<Descriptions.Item key={key} label={key}>
{renderValue(row[key], types[key])}
</Descriptions.Item>
))}
</Descriptions>
{data.rows.length > 1 && (
<div className="paginator-container">
<Pagination
showSizeChanger={false}
current={page + 1}
defaultPageSize={1}
total={data.rows.length}
onChange={p => setPage(p - 1)}
/>
</div>
)}
</div>
);
}
DetailsRenderer.propTypes = RendererPropTypes;

View File

@@ -0,0 +1,31 @@
import React from "react";
import SharedColumnEditor from "../../shared/components/ColumnEditor";
type OwnProps = {
column: {
name: string;
title?: string;
visible?: boolean;
alignContent?: "left" | "center" | "right";
displayAs?: any;
description?: string;
};
onChange?: (...args: any[]) => any;
};
type Props = OwnProps & typeof ColumnEditor.defaultProps;
export default function ColumnEditor({ column, onChange }: Props) {
return (
<SharedColumnEditor
column={column}
onChange={onChange}
variant="details"
showSearch={false}
/>
);
}
ColumnEditor.defaultProps = {
onChange: (...args: any[]) => {},
};

View File

@@ -0,0 +1,98 @@
import React from "react";
import enzyme from "enzyme";
import getOptions from "../getOptions";
import ColumnsSettings from "./ColumnsSettings";
function findByTestID(wrapper: any, testId: any) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(options: any, done: any) {
const data = {
columns: [
{ name: "id", type: "integer" },
{ name: "name", type: "string" },
{ name: "created_at", type: "datetime" },
],
rows: [{ id: 1, name: "test", created_at: "2023-01-01T00:00:00Z" }],
};
options = getOptions(options, data);
return enzyme.mount(
<ColumnsSettings
visualizationName="Details"
data={data}
options={options}
onOptionsChange={changedOptions => {
expect(changedOptions).toMatchSnapshot();
done();
}}
/>
);
}
describe("Visualizations -> Details -> Editor -> Columns Settings", () => {
test("Toggles column visibility", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.id.Visibility")
.last()
.simulate("click");
});
test("Changes column title", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.name.Name")
.last()
.simulate("click"); // expand settings
findByTestID(el, "Details.Column.name.Title")
.last()
.simulate("change", { target: { value: "Full Name" } });
});
test("Changes column alignment", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.id.Name")
.last()
.simulate("click"); // expand settings
findByTestID(el, "Details.Column.id.TextAlignment")
.last()
.find('[data-test="TextAlignmentSelect.Center"] input')
.simulate("change", { target: { checked: true } });
});
test("Changes column description", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.name.Name")
.last()
.simulate("click"); // expand settings
findByTestID(el, "Details.Column.name.Description")
.last()
.simulate("change", { target: { value: "User full name" } });
});
test("Changes column display type", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.created_at.Name")
.last()
.simulate("click"); // expand settings
findByTestID(el, "Details.Column.created_at.DisplayAs")
.last()
.simulate("mouseDown");
findByTestID(el, "Details.Column.created_at.DisplayAs.string")
.last()
.simulate("click");
});
test("Hides multiple columns", done => {
const el = mount({}, done);
findByTestID(el, "Details.Column.id.Visibility")
.last()
.simulate("click");
});
});

View File

@@ -0,0 +1,15 @@
import React from "react";
import SharedColumnsSettings from "../../shared/components/ColumnsSettings";
import { EditorPropTypes } from "@/visualizations/prop-types";
export default function ColumnsSettings({ options, onOptionsChange, data }: any) {
return (
<SharedColumnsSettings
options={options}
onOptionsChange={onOptionsChange}
variant="details"
/>
);
}
ColumnsSettings.propTypes = EditorPropTypes;

View File

@@ -0,0 +1,529 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Visualizations -> Details -> Editor -> Columns Settings Changes column alignment 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "center",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "datetime",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;
exports[`Visualizations -> Details -> Editor -> Columns Settings Changes column description 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "User full name",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "datetime",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;
exports[`Visualizations -> Details -> Editor -> Columns Settings Changes column display type 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;
exports[`Visualizations -> Details -> Editor -> Columns Settings Changes column title 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "Full Name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "datetime",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;
exports[`Visualizations -> Details -> Editor -> Columns Settings Hides multiple columns 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": false,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "datetime",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;
exports[`Visualizations -> Details -> Editor -> Columns Settings Toggles column visibility 1`] = `
Object {
"columns": Array [
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "id",
"nullValue": "null",
"numberFormat": "0,0",
"order": 100000,
"title": "id",
"type": "integer",
"visible": false,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "name",
"nullValue": "null",
"numberFormat": undefined,
"order": 100001,
"title": "name",
"type": "string",
"visible": true,
},
Object {
"alignContent": "left",
"allowHTML": false,
"booleanValues": Array [
"false",
"true",
],
"dateTimeFormat": "DD/MM/YYYY HH:mm",
"description": "",
"displayAs": "datetime",
"highlightLinks": false,
"imageHeight": "",
"imageTitleTemplate": "{{ @ }}",
"imageUrlTemplate": "{{ @ }}",
"imageWidth": "",
"linkOpenInNewTab": true,
"linkTextTemplate": "{{ @ }}",
"linkTitleTemplate": "{{ @ }}",
"linkUrlTemplate": "{{ @ }}",
"name": "created_at",
"nullValue": "null",
"numberFormat": undefined,
"order": 100002,
"title": "created_at",
"type": "datetime",
"visible": true,
},
],
}
`;

View File

@@ -0,0 +1,33 @@
.details-visualization-editor-columns {
.ant-collapse {
background: transparent;
}
.ant-collapse-item {
background: #ffffff;
.drag-handle {
height: 20px;
margin-left: -16px;
padding: 0 16px;
}
}
.details-editor-columns-dragged-item {
z-index: 1;
}
}
.details-visualization-editor-column {
padding-left: 6px;
.image-dimension-selector {
display: flex;
align-items: center;
.image-dimension-selector-spacer {
padding-left: 5px;
padding-right: 5px;
}
}
}

View File

@@ -0,0 +1,9 @@
import createTabbedEditor from "@/components/visualizations/editor/createTabbedEditor";
import ColumnsSettings from "./ColumnsSettings";
import "./editor.less";
export default createTabbedEditor([
{ key: "Columns", title: "Columns", component: ColumnsSettings },
]);

View File

@@ -0,0 +1,179 @@
import React from "react";
import enzyme from "enzyme";
import moment from "moment";
import Renderer from "./Renderer";
import getOptions from "./getOptions";
function mount(data: any, options: any = {}) {
options = getOptions(options, data);
return enzyme.mount(<Renderer data={data} options={options} />);
}
describe("Visualizations -> Details -> Renderer", () => {
const sampleData = {
columns: [
{ name: "id", type: "integer" },
{ name: "name", type: "string" },
{ name: "created_at", type: "datetime" },
{ name: "active", type: "boolean" },
],
rows: [
{
id: 1,
name: "John Doe",
created_at: moment("2023-01-01T12:00:00Z"),
active: true,
},
{
id: 2,
name: "Jane Smith",
created_at: moment("2023-02-01T12:00:00Z"),
active: false,
},
],
};
test("Renders all columns when no options provided", () => {
const el = mount(sampleData);
// Check that the component renders with expected data
expect(el.text()).toContain("id");
expect(el.text()).toContain("name");
expect(el.text()).toContain("created_at");
expect(el.text()).toContain("active");
expect(el.text()).toContain("1"); // id value
expect(el.text()).toContain("John Doe"); // name value
});
test("Renders only visible columns", () => {
const options = {
columns: [
{ name: "id", visible: true, order: 0 },
{ name: "name", visible: false, order: 1 },
{ name: "created_at", visible: true, order: 2 },
{ name: "active", visible: false, order: 3 },
],
};
const el = mount(sampleData, options);
// Should show id and created_at, but not name and active
expect(el.text()).toContain("id");
expect(el.text()).toContain("created_at");
expect(el.text()).not.toContain("name");
expect(el.text()).not.toContain("active");
});
test("Respects column order", () => {
const options = {
columns: [
{ name: "active", visible: true, order: 0 },
{ name: "name", visible: true, order: 1 },
{ name: "created_at", visible: true, order: 2 },
{ name: "id", visible: true, order: 3 },
],
};
const el = mount(sampleData, options);
// Get all description item labels in order
const labels = el.find('.ant-descriptions-item-label').map(node => node.text());
// Should appear in order: active (0), name (1), created_at (2), id (3)
expect(labels).toEqual(['active', 'name', 'created_at', 'id']);
});
test("Uses custom column titles", () => {
const options = {
columns: [
{ name: "id", visible: true, title: "User ID", order: 0 },
{ name: "name", visible: true, title: "Full Name", order: 1 },
],
};
const el = mount(sampleData, options);
expect(el.text()).toContain("User ID");
expect(el.text()).toContain("Full Name");
});
test("Applies text alignment", () => {
const options = {
columns: [
{ name: "id", visible: true, alignContent: "center", order: 0 },
{ name: "name", visible: true, alignContent: "right", order: 1 },
],
};
const el = mount(sampleData, options);
// Check that alignment styles are applied
const alignedDivs = el.find('div[style]');
expect(alignedDivs.length).toBeGreaterThan(0);
});
test("Shows pagination for multiple rows", () => {
const el = mount(sampleData);
// Check that pagination is present - look for pagination elements
const paginationElements = el.find('[className*="paginator"]');
expect(paginationElements.length).toBeGreaterThan(0);
});
test("Hides pagination for single row", () => {
const singleRowData = {
...sampleData,
rows: [sampleData.rows[0]],
};
const el = mount(singleRowData);
// Check that pagination is not present for single row
const paginationElements = el.find('[className*="paginator"]');
expect(paginationElements.length).toBe(0);
});
test("Handles empty data", () => {
const emptyData = {
columns: [],
rows: [],
};
const el = mount(emptyData);
expect(el.html()).toBeNull();
});
test("Handles null data", () => {
// Suppress PropTypes warning for this test
const originalError = console.error;
console.error = jest.fn();
// Test the component directly with null data instead of using mount helper
const el = enzyme.mount(<Renderer data={null as any} options={{}} />);
expect(el.html()).toBeNull();
// Restore console.error
console.error = originalError;
});
test("Navigates between rows with pagination", () => {
const el = mount(sampleData);
// Check first row is displayed
expect(el.text()).toContain("John Doe");
expect(el.text()).not.toContain("Jane Smith");
// Find and click next button
const nextButton = el.find('button').filterWhere(n => n.text().includes('Next') || n.prop('aria-label') === 'Next Page');
if (nextButton.length > 0) {
nextButton.first().simulate("click");
// Check second row is displayed after state update
el.update();
expect(el.text()).toContain("Jane Smith");
}
});
});

View File

@@ -0,0 +1,82 @@
import React, { useState, useMemo } from "react";
import { map, filter, sortBy } from "lodash";
import { RendererPropTypes } from "@/visualizations/prop-types";
import Descriptions from "antd/lib/descriptions";
import Pagination from "antd/lib/pagination";
import Tooltip from "antd/lib/tooltip";
import ColumnTypes from "../shared/columns";
import "./details.less";
export default function Renderer({ data, options }: any) {
const [page, setPage] = useState(0);
const visibleColumns = useMemo(() => {
if (!options?.columns) return [];
const columns = sortBy(filter(options.columns, "visible"), "order");
return columns.map((column: any) => {
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
const ColumnType = ColumnTypes[column.displayAs] || ColumnTypes.string;
const Component = ColumnType(column);
return {
...column,
Component,
};
});
}, [options?.columns]);
if (!data || !data.rows || data.rows.length === 0) {
return null;
}
const row = data.rows[page];
return (
<div className="details-viz">
<Descriptions size="small" column={1} bordered>
{map(visibleColumns, column => {
const { Component } = column;
return (
<Descriptions.Item
key={column.name}
label={
<React.Fragment>
{column.description && (
<span style={{ paddingRight: 5 }}>
<Tooltip placement="top" title={column.description}>
<i className="fa fa-info-circle" aria-hidden="true"></i>
</Tooltip>
</span>
)}
{column.title || column.name}
</React.Fragment>
}
>
<div style={{ textAlign: column.alignContent || "left" }}>
<Component row={row} />
</div>
</Descriptions.Item>
);
})}
</Descriptions>
{data.rows.length > 1 && (
<div className="paginator-container">
<Pagination
showSizeChanger={false}
current={page + 1}
defaultPageSize={1}
total={data.rows.length}
onChange={p => setPage(p - 1)}
/>
</div>
)}
</div>
);
}
Renderer.propTypes = RendererPropTypes;

View File

@@ -0,0 +1,160 @@
import getOptions from "./getOptions";
describe("Visualizations -> Details -> getOptions", () => {
const sampleData = {
columns: [
{ name: "id", type: "integer" },
{ name: "name", type: "string" },
{ name: "created_at", type: "datetime" },
{ name: "is_active", type: "boolean" },
{ name: "score", type: "float" },
],
};
test("Returns default options when no options provided", () => {
const result = getOptions({}, sampleData);
expect(result.columns).toHaveLength(5);
expect(result.columns[0]).toEqual(
expect.objectContaining({
name: "id",
type: "integer",
displayAs: "number",
visible: true,
alignContent: "left",
title: "id",
description: "",
allowHTML: false,
highlightLinks: false,
})
);
});
test("Preserves existing column options", () => {
const existingOptions = {
columns: [
{
name: "id",
visible: false,
title: "User ID",
alignContent: "center",
},
],
};
const result = getOptions(existingOptions, sampleData);
const idColumn = result.columns.find((col: any) => col.name === "id");
expect(idColumn).toEqual(
expect.objectContaining({
visible: false,
title: "User ID",
alignContent: "center",
})
);
});
test("Sets correct default display types", () => {
const result = getOptions({}, sampleData);
const columnsByName = result.columns.reduce((acc: any, col: any) => {
acc[col.name] = col;
return acc;
}, {} as any);
expect(columnsByName.id.displayAs).toBe("number");
expect(columnsByName.name.displayAs).toBe("string");
expect(columnsByName.created_at.displayAs).toBe("datetime");
expect(columnsByName.is_active.displayAs).toBe("boolean");
expect(columnsByName.score.displayAs).toBe("number");
});
test("Sets correct default alignments", () => {
const result = getOptions({}, sampleData);
const columnsByName = result.columns.reduce((acc: any, col: any) => {
acc[col.name] = col;
return acc;
}, {} as any);
expect(columnsByName.id.alignContent).toBe("left");
expect(columnsByName.name.alignContent).toBe("left");
expect(columnsByName.created_at.alignContent).toBe("left");
expect(columnsByName.is_active.alignContent).toBe("left");
expect(columnsByName.score.alignContent).toBe("left");
});
test("Handles column name type suffixes", () => {
const dataWithTypeSuffixes = {
columns: [
{ name: "user::filter", type: "string" },
{ name: "amount__multiFilter", type: "float" },
{ name: "::date_field", type: "date" },
],
};
const result = getOptions({}, dataWithTypeSuffixes);
expect(result.columns[0].title).toBe("user");
expect(result.columns[1].title).toBe("amount");
expect(result.columns[2].title).toBe("date_field");
});
test("Maintains column order from existing options", () => {
const existingOptions = {
columns: [
{ name: "name", order: 0 },
{ name: "id", order: 1 },
],
};
const result = getOptions(existingOptions, sampleData);
expect(result.columns[0].name).toBe("name");
expect(result.columns[1].name).toBe("id");
});
test("Handles missing columns in existing options", () => {
const existingOptions = {
columns: [
{ name: "id", visible: false },
{ name: "nonexistent", visible: true },
],
};
const result = getOptions(existingOptions, sampleData);
// Should include all data columns
expect(result.columns).toHaveLength(5);
// Should preserve settings for existing columns
const idColumn = result.columns.find((col: any) => col.name === "id");
expect(idColumn.visible).toBe(false);
});
test("Includes default format options", () => {
const result = getOptions({}, sampleData);
const column = result.columns[0];
expect(column).toEqual(
expect.objectContaining({
booleanValues: ["false", "true"],
imageUrlTemplate: "{{ @ }}",
imageTitleTemplate: "{{ @ }}",
imageWidth: "",
imageHeight: "",
linkUrlTemplate: "{{ @ }}",
linkTextTemplate: "{{ @ }}",
linkTitleTemplate: "{{ @ }}",
linkOpenInNewTab: true,
})
);
});
test("Handles empty data", () => {
const emptyData = { columns: [] };
const result = getOptions({}, emptyData);
expect(result.columns).toEqual([]);
});
});

View File

@@ -0,0 +1,17 @@
import _ from "lodash";
import {
getDefaultFormatOptions,
getColumnsOptions,
} from "@/visualizations/shared/columnUtils";
const DEFAULT_OPTIONS = {};
export default function getOptions(options: any, { columns }: any) {
options = { ...DEFAULT_OPTIONS, ...options };
options.columns = _.map(getColumnsOptions(columns, options.columns, { alignContent: "left" }), col => ({
...getDefaultFormatOptions(col),
...col,
}));
return options;
}

View File

@@ -1,15 +1,13 @@
import DetailsRenderer from "./DetailsRenderer";
const DEFAULT_OPTIONS = {};
import getOptions from "./getOptions";
import Renderer from "./Renderer";
import Editor from "./Editor";
export default {
type: "DETAILS",
name: "Details View",
getOptions: (options: any) => ({
...DEFAULT_OPTIONS,
...options,
}),
Renderer: DetailsRenderer,
getOptions,
Renderer,
Editor,
defaultColumns: 4,
defaultRows: 2,
};

View File

@@ -0,0 +1,126 @@
import _ from "lodash";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
const filterTypes = ["filter", "multi-filter", "multiFilter"];
export function getColumnNameWithoutType(column: any) {
let typeSplit;
if (column.indexOf("::") !== -1) {
typeSplit = "::";
} else if (column.indexOf("__") !== -1) {
typeSplit = "__";
} else {
return column;
}
const parts = column.split(typeSplit);
if (parts[0] === "" && parts.length === 2) {
return parts[1];
}
if (!_.includes(filterTypes, parts[1])) {
return column;
}
return parts[0];
}
export function getColumnContentAlignment(type: any) {
return ["integer", "float", "boolean", "date", "datetime"].indexOf(type) >= 0 ? "right" : "left";
}
export function getDefaultColumnsOptions(columns: any, extraFields = {}) {
const displayAs = {
integer: "number",
float: "number",
boolean: "boolean",
date: "datetime",
datetime: "datetime",
};
const defaultFields = {
// `string` cell options
allowHTML: false,
highlightLinks: false,
};
return _.map(columns, (col, index) => ({
name: col.name,
type: col.type,
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
displayAs: displayAs[col.type] || "string",
visible: true,
order: 100000 + index,
title: getColumnNameWithoutType(col.name),
alignContent: getColumnContentAlignment(col.type),
description: "",
...defaultFields,
...extraFields,
}));
}
export function getDefaultFormatOptions(column: any) {
const dateTimeFormat = {
date: visualizationsSettings.dateFormat || "DD/MM/YYYY",
datetime: visualizationsSettings.dateTimeFormat || "DD/MM/YYYY HH:mm",
};
const numberFormat = {
integer: visualizationsSettings.integerFormat || "0,0",
float: visualizationsSettings.floatFormat || "0,0.00",
};
return {
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
dateTimeFormat: dateTimeFormat[column.type],
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
numberFormat: numberFormat[column.type],
nullValue: visualizationsSettings.nullValue,
booleanValues: visualizationsSettings.booleanValues || ["false", "true"],
// `image` cell options
imageUrlTemplate: "{{ @ }}",
imageTitleTemplate: "{{ @ }}",
imageWidth: "",
imageHeight: "",
// `link` cell options
linkUrlTemplate: "{{ @ }}",
linkTextTemplate: "{{ @ }}",
linkTitleTemplate: "{{ @ }}",
linkOpenInNewTab: true,
};
}
export function wereColumnsReordered(queryColumns: any, visualizationColumns: any) {
queryColumns = _.map(queryColumns, col => col.name);
visualizationColumns = _.map(visualizationColumns, col => col.name);
// Some columns may be removed - so skip them (but keep original order)
visualizationColumns = _.filter(visualizationColumns, col => _.includes(queryColumns, col));
// Pick query columns that were previously saved with viz (but keep order too)
queryColumns = _.filter(queryColumns, col => _.includes(visualizationColumns, col));
// Both array now have the same size as they both contains only common columns
// (in fact, it was an intersection, that kept order of items on both arrays).
// Now check for equality item-by-item; if common columns are in the same order -
// they were not reordered in editor
for (let i = 0; i < queryColumns.length; i += 1) {
if (visualizationColumns[i] !== queryColumns[i]) {
return true;
}
}
return false;
}
export function getColumnsOptions(columns: any, visualizationColumns: any, extraFields = {}) {
const options = getDefaultColumnsOptions(columns, extraFields);
if (wereColumnsReordered(columns, visualizationColumns)) {
visualizationColumns = _.fromPairs(
_.map(visualizationColumns, (col, index) => [col.name, _.extend({}, col, { order: index })])
);
} else {
visualizationColumns = _.fromPairs(_.map(visualizationColumns, col => [col.name, _.omit(col, "order")]));
}
_.each(options, col => _.extend(col, visualizationColumns[col.name]));
return _.sortBy(options, "order");
}

View File

@@ -0,0 +1,225 @@
import React from "react";
import enzyme from "enzyme";
import ColumnEditor from "./ColumnEditor";
function findByTestID(wrapper: any, testId: any) {
return wrapper.find(`[data-test="${testId}"]`);
}
function mount(column: any, variant: "table" | "details", onChange: any = jest.fn()) {
return enzyme.mount(
<ColumnEditor
column={column}
variant={variant}
onChange={onChange}
/>
);
}
const mockColumn = {
name: "user_id",
title: "user_id",
visible: true,
alignContent: "left" as const,
displayAs: "string",
description: "",
allowSearch: false,
};
describe("Shared ColumnEditor", () => {
describe("Common functionality", () => {
test.each(["table", "details"] as const)("Changes column title - %s variant", async (variant) => {
return new Promise<void>((resolve) => {
const onChange = jest.fn((changes) => {
expect(changes).toEqual({
...mockColumn,
title: "User ID",
});
resolve();
});
const el = mount(mockColumn, variant, onChange);
const testPrefix = variant === "table" ? "Table" : "Details";
findByTestID(el, `${testPrefix}.Column.user_id.Title`)
.find("input")
.simulate("change", { target: { value: "User ID" } });
});
});
test.each(["table", "details"] as const)("Changes column alignment - %s variant", (variant) => {
const onChange = jest.fn();
const el = mount({
...mockColumn,
name: "amount",
displayAs: "number",
}, variant, onChange);
const testPrefix = variant === "table" ? "Table" : "Details";
findByTestID(el, `${testPrefix}.Column.amount.TextAlignment`)
.find('input[value="right"]')
.simulate("change", { target: { value: "right" } });
expect(onChange).toHaveBeenCalledWith({
...mockColumn,
name: "amount",
displayAs: "number",
alignContent: "right",
});
});
test.each(["table", "details"] as const)("Changes column description - %s variant", async (variant) => {
return new Promise<void>((resolve) => {
const onChange = jest.fn((changes) => {
expect(changes).toEqual({
...mockColumn,
name: "status",
title: "Status",
description: "Current order status",
});
resolve();
});
const el = mount({
...mockColumn,
name: "status",
title: "Status",
}, variant, onChange);
const testPrefix = variant === "table" ? "Table" : "Details";
findByTestID(el, `${testPrefix}.Column.status.Description`)
.find("input")
.simulate("change", { target: { value: "Current order status" } });
});
});
test.each(["table", "details"] as const)("Changes display type - %s variant", (variant) => {
const onChange = jest.fn();
const el = mount({
...mockColumn,
name: "created_at",
title: "Created At",
displayAs: "datetime",
}, variant, onChange);
const testPrefix = variant === "table" ? "Table" : "Details";
findByTestID(el, `${testPrefix}.Column.created_at.DisplayAs`)
.find(".ant-select-selector")
.simulate("mouseDown");
findByTestID(el, `${testPrefix}.Column.created_at.DisplayAs.string`)
.simulate("click");
expect(onChange).toHaveBeenCalledWith({
...mockColumn,
name: "created_at",
title: "Created At",
displayAs: "string",
});
});
});
describe("Table variant specific", () => {
test("Shows search checkbox", () => {
const el = mount(mockColumn, "table");
const searchCheckbox = findByTestID(el, "Table.Column.user_id.UseForSearch");
expect(searchCheckbox.find("input[type='checkbox']")).toHaveLength(1);
});
test("Changes search setting", () => {
const onChange = jest.fn();
const el = mount({
...mockColumn,
allowSearch: false,
}, "table", onChange);
findByTestID(el, "Table.Column.user_id.UseForSearch")
.find("input[type='checkbox']")
.simulate("change", { target: { checked: true } });
expect(onChange).toHaveBeenCalledWith({
...mockColumn,
allowSearch: true,
});
});
test("Uses correct CSS class", () => {
const el = mount(mockColumn, "table");
expect(el.find(".table-visualization-editor-column")).toHaveLength(1);
});
});
describe("Details variant specific", () => {
test("Hides search checkbox", () => {
const el = mount(mockColumn, "details");
const searchCheckbox = findByTestID(el, "Details.Column.user_id.UseForSearch");
expect(searchCheckbox).toHaveLength(0);
});
test("Uses correct CSS class", () => {
const el = mount(mockColumn, "details");
expect(el.find(".details-visualization-editor-column")).toHaveLength(1);
});
});
describe("Props and defaults", () => {
test("Uses default showSearch based on variant", () => {
const tableEl = mount(mockColumn, "table");
const detailsEl = mount(mockColumn, "details");
expect(findByTestID(tableEl, "Table.Column.user_id.UseForSearch").find("input[type='checkbox']")).toHaveLength(1);
expect(findByTestID(detailsEl, "Details.Column.user_id.UseForSearch")).toHaveLength(0);
});
test("Allows custom testPrefix", () => {
const el = mount(mockColumn, "table");
el.setProps({ testPrefix: "Custom.Prefix" });
el.update();
expect(findByTestID(el, "Custom.Prefix.Title").find("input")).toHaveLength(1);
});
test("Handles missing onChange gracefully", () => {
const el = mount(mockColumn, "table", undefined);
expect(() => {
findByTestID(el, "Table.Column.user_id.Title")
.find("input")
.simulate("change", { target: { value: "New Title" } });
}).not.toThrow();
});
});
describe("Rendering", () => {
test("Table variant renders with correct structure", () => {
const el = mount({
...mockColumn,
allowSearch: true,
description: "Sample description",
}, "table");
// Verify key elements are present
expect(el.find('.table-visualization-editor-column')).toHaveLength(1);
expect(findByTestID(el, "Table.Column.user_id.Title").find("input")).toHaveLength(1);
expect(findByTestID(el, "Table.Column.user_id.TextAlignment").find("input[type='radio']")).toHaveLength(3);
expect(findByTestID(el, "Table.Column.user_id.UseForSearch").find("input[type='checkbox']")).toHaveLength(1);
expect(findByTestID(el, "Table.Column.user_id.Description").find("input")).toHaveLength(1);
expect(findByTestID(el, "Table.Column.user_id.DisplayAs")).toHaveLength(7); // Expected count based on current behavior
});
test("Details variant renders with correct structure", () => {
const el = mount({
...mockColumn,
description: "Sample description",
}, "details");
// Verify key elements are present
expect(el.find('.details-visualization-editor-column')).toHaveLength(1);
expect(findByTestID(el, "Details.Column.user_id.Title").find("input")).toHaveLength(1);
expect(findByTestID(el, "Details.Column.user_id.TextAlignment").find("input[type='radio']")).toHaveLength(3);
expect(findByTestID(el, "Details.Column.user_id.UseForSearch")).toHaveLength(0); // Should not exist
expect(findByTestID(el, "Details.Column.user_id.Description").find("input")).toHaveLength(1);
expect(findByTestID(el, "Details.Column.user_id.DisplayAs")).toHaveLength(7); // Expected count based on current behavior
});
});
});

View File

@@ -0,0 +1,117 @@
import { map } from "lodash";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid";
import { Section, Select, Input, Checkbox, TextAlignmentSelect } from "@/components/visualizations/editor";
import ColumnTypes from "../columns";
type Column = {
name: string;
title?: string;
visible?: boolean;
alignContent?: "left" | "center" | "right";
displayAs?: any;
description?: string;
allowSearch?: boolean;
};
type ColumnEditorProps = {
column: Column;
onChange?: (changes: any) => any;
variant: "table" | "details";
showSearch?: boolean;
testPrefix?: string;
};
export default function ColumnEditor({
column,
onChange,
variant,
showSearch = variant === "table",
testPrefix,
}: ColumnEditorProps) {
function handleChange(changes: any) {
if (onChange) {
onChange({ ...column, ...changes });
}
}
const [handleChangeDebounced] = useDebouncedCallback(handleChange, 200);
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
const AdditionalOptions = ColumnTypes[column.displayAs].Editor || null;
const cssClass = `${variant}-visualization-editor-column`;
const dataTestPrefix = testPrefix || `${variant === "table" ? "Table" : "Details"}.Column.${column.name}`;
return (
<div className={cssClass}>
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ children: Element[]; gutter: number; type:... Remove this comment to see the full error message */}
<Grid.Row gutter={15} type="flex" align="middle">
<Grid.Col span={16}>
<Input
data-test={`${dataTestPrefix}.Title`}
defaultValue={column.title}
onChange={(event: any) => handleChangeDebounced({ title: event.target.value })}
/>
</Grid.Col>
<Grid.Col span={8}>
<TextAlignmentSelect
data-test={`${dataTestPrefix}.TextAlignment`}
defaultValue={column.alignContent}
onChange={(event: any) => handleChange({ alignContent: event.target.value })}
/>
</Grid.Col>
</Grid.Row>
</Section>
{showSearch && (
/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */
<Section>
<Checkbox
data-test={`${dataTestPrefix}.UseForSearch`}
defaultChecked={column.allowSearch}
onChange={event => handleChange({ allowSearch: event.target.checked })}>
Use for search
</Checkbox>
</Section>
)}
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
<Input
label="Description"
data-test={`${dataTestPrefix}.Description`}
defaultValue={column.description}
onChange={(event: any) => handleChangeDebounced({ description: event.target.value })}
/>
</Section>
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
<Select
label="Display as:"
data-test={`${dataTestPrefix}.DisplayAs`}
defaultValue={column.displayAs}
onChange={(displayAs: any) => handleChange({ displayAs })}>
{map(ColumnTypes, ({ friendlyName }, key) => (
// @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message
<Select.Option key={key} data-test={`${dataTestPrefix}.DisplayAs.${key}`}>
{friendlyName}
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
))}
</Select>
</Section>
{AdditionalOptions && <AdditionalOptions column={column} onChange={handleChange} />}
</div>
);
}
ColumnEditor.defaultProps = {
onChange: () => {},
};

View File

@@ -0,0 +1,102 @@
import { map } from "lodash";
import React from "react";
import Collapse from "antd/lib/collapse";
import Tooltip from "antd/lib/tooltip";
import Typography from "antd/lib/typography";
// @ts-expect-error ts-migrate(2724) FIXME: Module '"../../../../node_modules/react-sortable-h... Remove this comment to see the full error message
import { sortableElement } from "react-sortable-hoc";
import { SortableContainer, DragHandle } from "@/components/sortable";
import PropTypes from "prop-types";
import EyeOutlinedIcon from "@ant-design/icons/EyeOutlined";
import EyeInvisibleOutlinedIcon from "@ant-design/icons/EyeInvisibleOutlined";
import ColumnEditor from "./ColumnEditor";
const { Text } = Typography;
const SortableItem = sortableElement(Collapse.Panel);
type ColumnsSettingsProps = {
options: any;
onOptionsChange: any;
variant: "table" | "details";
};
export default function ColumnsSettings({ options, onOptionsChange, variant }: ColumnsSettingsProps) {
function handleColumnChange(newColumn: any, event: any) {
if (event) {
event.stopPropagation();
}
const columns = map(options.columns, c => (c.name === newColumn.name ? newColumn : c));
onOptionsChange({ columns });
}
function handleColumnsReorder({ oldIndex, newIndex }: any) {
const columns = [...options.columns];
columns.splice(newIndex, 0, ...columns.splice(oldIndex, 1));
onOptionsChange({ columns });
}
const helperClass = `${variant}-editor-columns-dragged-item`;
const containerClass = `${variant}-visualization-editor-columns`;
const testPrefix = variant === "table" ? "Table" : "Details";
return (
<SortableContainer
axis="y"
lockAxis="y"
useDragHandle
helperClass={helperClass}
helperContainer={(container: any) => container.firstChild}
onSortEnd={handleColumnsReorder}
containerProps={{
className: containerClass,
}}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
<Collapse bordered={false} defaultActiveKey={[]} expandIconPosition="right">
{map(options.columns, (column, index) => (
<SortableItem
key={column.name}
index={index}
header={
<React.Fragment>
<DragHandle />
<span data-test={`${testPrefix}.Column.${column.name}.Name`}>
{column.name}
{column.title !== "" && column.title !== column.name && (
<Text type="secondary" style={{ marginLeft: 5 }}>
<i>({column.title})</i>
</Text>
)}
</span>
</React.Fragment>
}
extra={
<Tooltip title="Toggle visibility" mouseEnterDelay={0} mouseLeaveDelay={0}>
{column.visible ? (
<EyeOutlinedIcon
data-test={`${testPrefix}.Column.${column.name}.Visibility`}
onClick={event => handleColumnChange({ ...column, visible: !column.visible }, event)}
/>
) : (
<EyeInvisibleOutlinedIcon
data-test={`${testPrefix}.Column.${column.name}.Visibility`}
onClick={event => handleColumnChange({ ...column, visible: !column.visible }, event)}
/>
)}
</Tooltip>
}>
<ColumnEditor column={column} variant={variant} onChange={(changes) => handleColumnChange(changes, undefined)} />
</SortableItem>
))}
</Collapse>
</SortableContainer>
);
}
ColumnsSettings.propTypes = {
options: PropTypes.object.isRequired,
onOptionsChange: PropTypes.func.isRequired,
variant: PropTypes.oneOf(["table", "details"]).isRequired,
};

View File

@@ -1,10 +1,5 @@
import { map, keys } from "lodash";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
import * as Grid from "antd/lib/grid";
import { Section, Select, Input, Checkbox, TextAlignmentSelect } from "@/components/visualizations/editor";
import ColumnTypes from "../columns";
import SharedColumnEditor from "../../shared/components/ColumnEditor";
type OwnProps = {
column: {
@@ -12,7 +7,9 @@ type OwnProps = {
title?: string;
visible?: boolean;
alignContent?: "left" | "center" | "right";
displayAs?: any; // TODO: PropTypes.oneOf(keys(ColumnTypes))
displayAs?: any;
allowSearch?: boolean;
description?: string;
};
onChange?: (...args: any[]) => any;
};
@@ -20,78 +17,13 @@ type OwnProps = {
type Props = OwnProps & typeof ColumnEditor.defaultProps;
export default function ColumnEditor({ column, onChange }: Props) {
function handleChange(changes: any) {
onChange({ ...column, ...changes });
}
const [handleChangeDebounced] = useDebouncedCallback(handleChange, 200);
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
const AdditionalOptions = ColumnTypes[column.displayAs].Editor || null;
return (
<div className="table-visualization-editor-column">
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '{ children: Element[]; gutter: number; type:... Remove this comment to see the full error message */}
<Grid.Row gutter={15} type="flex" align="middle">
<Grid.Col span={16}>
<Input
data-test={`Table.Column.${column.name}.Title`}
defaultValue={column.title}
onChange={(event: any) => handleChangeDebounced({ title: event.target.value })}
/>
</Grid.Col>
<Grid.Col span={8}>
<TextAlignmentSelect
data-test={`Table.Column.${column.name}.TextAlignment`}
defaultValue={column.alignContent}
onChange={(event: any) => handleChange({ alignContent: event.target.value })}
/>
</Grid.Col>
</Grid.Row>
</Section>
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
<Checkbox
data-test={`Table.Column.${column.name}.UseForSearch`}
// @ts-expect-error ts-migrate(2339) FIXME: Property 'allowSearch' does not exist on type '{ n... Remove this comment to see the full error message
defaultChecked={column.allowSearch}
onChange={event => handleChange({ allowSearch: event.target.checked })}>
Use for search
</Checkbox>
</Section>
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
<Input
label="Description"
// @ts-expect-error ts-migrate(2339) FIXME: Property 'description' does not exist on type '{ n... Remove this comment to see the full error message
defaultValue={column.description}
onChange={(event: any) => handleChangeDebounced({ description: event.target.value })}
/>
</Section>
{/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */}
<Section>
<Select
label="Display as:"
data-test={`Table.Column.${column.name}.DisplayAs`}
defaultValue={column.displayAs}
onChange={(displayAs: any) => handleChange({ displayAs })}>
{map(ColumnTypes, ({ friendlyName }, key) => (
// @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message
<Select.Option key={key} data-test={`Table.Column.${column.name}.DisplayAs.${key}`}>
{friendlyName}
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
))}
</Select>
</Section>
{AdditionalOptions && <AdditionalOptions column={column} onChange={handleChange} />}
</div>
<SharedColumnEditor
column={column}
onChange={onChange}
variant="table"
showSearch={true}
/>
);
}

View File

@@ -1,88 +1,14 @@
import { map } from "lodash";
import React from "react";
import Collapse from "antd/lib/collapse";
import Tooltip from "antd/lib/tooltip";
import Typography from "antd/lib/typography";
// @ts-expect-error ts-migrate(2724) FIXME: Module '"../../../../node_modules/react-sortable-h... Remove this comment to see the full error message
import { sortableElement } from "react-sortable-hoc";
import { SortableContainer, DragHandle } from "@/components/sortable";
import SharedColumnsSettings from "../../shared/components/ColumnsSettings";
import { EditorPropTypes } from "@/visualizations/prop-types";
import EyeOutlinedIcon from "@ant-design/icons/EyeOutlined";
import EyeInvisibleOutlinedIcon from "@ant-design/icons/EyeInvisibleOutlined";
import ColumnEditor from "./ColumnEditor";
const { Text } = Typography;
const SortableItem = sortableElement(Collapse.Panel);
export default function ColumnsSettings({ options, onOptionsChange }: any) {
function handleColumnChange(newColumn: any, event: any) {
if (event) {
event.stopPropagation();
}
const columns = map(options.columns, c => (c.name === newColumn.name ? newColumn : c));
onOptionsChange({ columns });
}
function handleColumnsReorder({ oldIndex, newIndex }: any) {
const columns = [...options.columns];
columns.splice(newIndex, 0, ...columns.splice(oldIndex, 1));
onOptionsChange({ columns });
}
export default function ColumnsSettings({ options, onOptionsChange, data }: any) {
return (
<SortableContainer
axis="y"
lockAxis="y"
useDragHandle
helperClass="table-editor-columns-dragged-item"
helperContainer={(container: any) => container.firstChild}
onSortEnd={handleColumnsReorder}
containerProps={{
className: "table-visualization-editor-columns",
}}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'null | u... Remove this comment to see the full error message */}
<Collapse bordered={false} defaultActiveKey={[]} expandIconPosition="right">
{map(options.columns, (column, index) => (
<SortableItem
key={column.name}
index={index}
header={
<React.Fragment>
<DragHandle />
<span data-test={`Table.Column.${column.name}.Name`}>
{column.name}
{column.title !== "" && column.title !== column.name && (
<Text type="secondary" style={{ marginLeft: 5 }}>
<i>({column.title})</i>
</Text>
)}
</span>
</React.Fragment>
}
extra={
<Tooltip title="Toggle visibility" mouseEnterDelay={0} mouseLeaveDelay={0}>
{column.visible ? (
<EyeOutlinedIcon
data-test={`Table.Column.${column.name}.Visibility`}
onClick={event => handleColumnChange({ ...column, visible: !column.visible }, event)}
/>
) : (
<EyeInvisibleOutlinedIcon
data-test={`Table.Column.${column.name}.Visibility`}
onClick={event => handleColumnChange({ ...column, visible: !column.visible }, event)}
/>
)}
</Tooltip>
}>
{/* @ts-expect-error ts-migrate(2322) FIXME: Type '(newColumn: any, event: any) => void' is not... Remove this comment to see the full error message */}
<ColumnEditor column={column} onChange={handleColumnChange} />
</SortableItem>
))}
</Collapse>
</SortableContainer>
<SharedColumnsSettings
options={options}
onOptionsChange={onOptionsChange}
variant="table"
/>
);
}

View File

@@ -12,6 +12,7 @@ Object {
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
@@ -46,6 +47,7 @@ Object {
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "number",
"highlightLinks": false,
"imageHeight": "",
@@ -80,6 +82,7 @@ Object {
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
@@ -114,6 +117,7 @@ Object {
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",
@@ -148,6 +152,7 @@ Object {
"true",
],
"dateTimeFormat": undefined,
"description": "",
"displayAs": "string",
"highlightLinks": false,
"imageHeight": "",

View File

@@ -1,133 +1,19 @@
import _ from "lodash";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
import {
getDefaultColumnsOptions,
getDefaultFormatOptions,
getColumnsOptions,
} from "@/visualizations/shared/columnUtils";
const DEFAULT_OPTIONS = {
itemsPerPage: 25,
paginationSize: "default", // not editable through Editor
};
const filterTypes = ["filter", "multi-filter", "multiFilter"];
function getColumnNameWithoutType(column: any) {
let typeSplit;
if (column.indexOf("::") !== -1) {
typeSplit = "::";
} else if (column.indexOf("__") !== -1) {
typeSplit = "__";
} else {
return column;
}
const parts = column.split(typeSplit);
if (parts[0] === "" && parts.length === 2) {
return parts[1];
}
if (!_.includes(filterTypes, parts[1])) {
return column;
}
return parts[0];
}
function getColumnContentAlignment(type: any) {
return ["integer", "float", "boolean", "date", "datetime"].indexOf(type) >= 0 ? "right" : "left";
}
function getDefaultColumnsOptions(columns: any) {
const displayAs = {
integer: "number",
float: "number",
boolean: "boolean",
date: "datetime",
datetime: "datetime",
};
return _.map(columns, (col, index) => ({
name: col.name,
type: col.type,
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
displayAs: displayAs[col.type] || "string",
visible: true,
order: 100000 + index,
title: getColumnNameWithoutType(col.name),
allowSearch: false,
alignContent: getColumnContentAlignment(col.type),
// `string` cell options
allowHTML: false,
highlightLinks: false,
}));
}
function getDefaultFormatOptions(column: any) {
const dateTimeFormat = {
date: visualizationsSettings.dateFormat || "DD/MM/YYYY",
datetime: visualizationsSettings.dateTimeFormat || "DD/MM/YYYY HH:mm",
};
const numberFormat = {
integer: visualizationsSettings.integerFormat || "0,0",
float: visualizationsSettings.floatFormat || "0,0.00",
};
return {
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
dateTimeFormat: dateTimeFormat[column.type],
// @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
numberFormat: numberFormat[column.type],
nullValue: visualizationsSettings.nullValue,
booleanValues: visualizationsSettings.booleanValues || ["false", "true"],
// `image` cell options
imageUrlTemplate: "{{ @ }}",
imageTitleTemplate: "{{ @ }}",
imageWidth: "",
imageHeight: "",
// `link` cell options
linkUrlTemplate: "{{ @ }}",
linkTextTemplate: "{{ @ }}",
linkTitleTemplate: "{{ @ }}",
linkOpenInNewTab: true,
};
}
function wereColumnsReordered(queryColumns: any, visualizationColumns: any) {
queryColumns = _.map(queryColumns, col => col.name);
visualizationColumns = _.map(visualizationColumns, col => col.name);
// Some columns may be removed - so skip them (but keep original order)
visualizationColumns = _.filter(visualizationColumns, col => _.includes(queryColumns, col));
// Pick query columns that were previously saved with viz (but keep order too)
queryColumns = _.filter(queryColumns, col => _.includes(visualizationColumns, col));
// Both array now have the same size as they both contains only common columns
// (in fact, it was an intersection, that kept order of items on both arrays).
// Now check for equality item-by-item; if common columns are in the same order -
// they were not reordered in editor
for (let i = 0; i < queryColumns.length; i += 1) {
if (visualizationColumns[i] !== queryColumns[i]) {
return true;
}
}
return false;
}
function getColumnsOptions(columns: any, visualizationColumns: any) {
const options = getDefaultColumnsOptions(columns);
if (wereColumnsReordered(columns, visualizationColumns)) {
visualizationColumns = _.fromPairs(
_.map(visualizationColumns, (col, index) => [col.name, _.extend({}, col, { order: index })])
);
} else {
visualizationColumns = _.fromPairs(_.map(visualizationColumns, col => [col.name, _.omit(col, "order")]));
}
_.each(options, col => _.extend(col, visualizationColumns[col.name]));
return _.sortBy(options, "order");
}
export default function getOptions(options: any, { columns }: any) {
options = { ...DEFAULT_OPTIONS, ...options };
options.columns = _.map(getColumnsOptions(columns, options.columns), col => ({
options.columns = _.map(getColumnsOptions(columns, options.columns, { allowSearch: false }), col => ({
...getDefaultFormatOptions(col),
...col,
}));

View File

@@ -2,7 +2,7 @@ import { isNil, map, get, filter, each, sortBy, some, findIndex, toString } from
import React from "react";
import cx from "classnames";
import Tooltip from "antd/lib/tooltip";
import ColumnTypes from "./columns";
import ColumnTypes from "../shared/columns";
function nextOrderByDirection(direction: any) {
switch (direction) {