diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx index 68a0ec2f7d..db0367035b 100644 --- a/client/app/components/ParameterValueInput.jsx +++ b/client/app/components/ParameterValueInput.jsx @@ -103,7 +103,6 @@ class ParameterValueInput extends React.Component { ({ label: String(opt), value: opt }))} @@ -122,7 +121,6 @@ class ParameterValueInput extends React.Component { {}, onPendingValuesChange: () => {}, onParametersEdit: () => {}, + appendSortableToParent: true, }; constructor(props) { @@ -85,7 +89,7 @@ export default class Parameters extends React.Component { if (oldIndex !== newIndex) { this.setState(({ parameters }) => { parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]); - onParametersEdit(); + onParametersEdit(parameters); return { parameters }; }); } @@ -110,7 +114,7 @@ export default class Parameters extends React.Component { this.setState(({ parameters }) => { const updatedParameter = extend(parameter, updated); parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId); - onParametersEdit(); + onParametersEdit(parameters); return { parameters }; }); }); @@ -146,15 +150,17 @@ export default class Parameters extends React.Component { render() { const { parameters } = this.state; - const { editable } = this.props; + const { sortable, appendSortableToParent } = this.props; const dirtyParamCount = size(filter(parameters, "hasPendingValue")); + return ( (appendSortableToParent ? containerEl : document.body)} updateBeforeSortStart={this.onBeforeSortStart} onSortEnd={this.moveParameter} containerProps={{ @@ -163,8 +169,11 @@ export default class Parameters extends React.Component { }}> {parameters.map((param, index) => ( -
- {editable && } +
+ {sortable && } {this.renderParameter(param, index)}
diff --git a/client/app/components/Parameters.less b/client/app/components/Parameters.less index ebd7616374..7a158e3e3f 100644 --- a/client/app/components/Parameters.less +++ b/client/app/components/Parameters.less @@ -21,6 +21,8 @@ &.parameter-dragged { z-index: 2; + margin: 4px 0 0 4px; + padding: 3px 6px 6px; box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); } } diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx index 020cde3f06..b37bcdb4a8 100644 --- a/client/app/components/QueryBasedParameterInput.jsx +++ b/client/app/components/QueryBasedParameterInput.jsx @@ -89,7 +89,6 @@ export default class QueryBasedParameterInput extends React.Component { value={this.state.value} onChange={onSelect} options={map(options, ({ value, name }) => ({ label: String(name), value }))} - optionFilterProp="children" showSearch showArrow notFoundContent={isEmpty(options) ? "No options available" : null} diff --git a/client/app/components/SelectWithVirtualScroll.tsx b/client/app/components/SelectWithVirtualScroll.tsx index 1e2df97ebb..73dde91a79 100644 --- a/client/app/components/SelectWithVirtualScroll.tsx +++ b/client/app/components/SelectWithVirtualScroll.tsx @@ -9,7 +9,7 @@ interface VirtualScrollLabeledValue extends LabeledValue { label: string; } -interface VirtualScrollSelectProps extends SelectProps { +interface VirtualScrollSelectProps extends Omit, "optionFilterProp" | "children"> { options: Array; } function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element { @@ -32,7 +32,14 @@ function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps return false; }, [options]); - return dropdownMatchSelectWidth={dropdownMatchSelectWidth} options={options} {...props} />; + return ( + + dropdownMatchSelectWidth={dropdownMatchSelectWidth} + options={options} + optionFilterProp="label" // as this component expects "options" prop + {...props} + /> + ); } export default SelectWithVirtualScroll; diff --git a/client/app/components/admin/RQStatus.jsx b/client/app/components/admin/RQStatus.jsx index 95c10562d8..606823b60e 100644 --- a/client/app/components/admin/RQStatus.jsx +++ b/client/app/components/admin/RQStatus.jsx @@ -35,11 +35,11 @@ CounterCard.defaultProps = { const queryJobsColumns = [ { title: "Queue", dataIndex: "origin" }, - { title: "Query ID", dataIndex: "meta.query_id" }, - { title: "Org ID", dataIndex: "meta.org_id" }, - { title: "Data Source ID", dataIndex: "meta.data_source_id" }, - { title: "User ID", dataIndex: "meta.user_id" }, - Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }), + { title: "Query ID", dataIndex: ["meta", "query_id"] }, + { title: "Org ID", dataIndex: ["meta", "org_id"] }, + { title: "Data Source ID", dataIndex: ["meta", "data_source_id"] }, + { title: "User ID", dataIndex: ["meta", "user_id"] }, + Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }), Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }), Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }), ]; diff --git a/client/app/components/dashboards/DashboardGrid.jsx b/client/app/components/dashboards/DashboardGrid.jsx index 3e170f357b..8aad1ad9c1 100644 --- a/client/app/components/dashboards/DashboardGrid.jsx +++ b/client/app/components/dashboards/DashboardGrid.jsx @@ -41,6 +41,7 @@ const DashboardWidget = React.memo( onRefreshWidget, onRemoveWidget, onParameterMappingsChange, + isEditing, canEdit, isPublic, isLoading, @@ -57,6 +58,7 @@ const DashboardWidget = React.memo( widget={widget} dashboard={dashboard} filters={filters} + isEditing={isEditing} canEdit={canEdit} isPublic={isPublic} isLoading={isLoading} @@ -77,7 +79,8 @@ const DashboardWidget = React.memo( prevProps.canEdit === nextProps.canEdit && prevProps.isPublic === nextProps.isPublic && prevProps.isLoading === nextProps.isLoading && - prevProps.filters === nextProps.filters + prevProps.filters === nextProps.filters && + prevProps.isEditing === nextProps.isEditing ); class DashboardGrid extends React.Component { @@ -223,7 +226,6 @@ class DashboardGrid extends React.Component { }); render() { - const className = cx("dashboard-wrapper", this.props.isEditing ? "editing-mode" : "preview-mode"); const { onLoadWidget, onRefreshWidget, @@ -232,19 +234,21 @@ class DashboardGrid extends React.Component { filters, dashboard, isPublic, + isEditing, widgets, } = this.props; + const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode"); return (
{!isEmpty(parameters) && (
- +
)} @@ -115,12 +128,16 @@ VisualizationWidgetHeader.propTypes = { widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types refreshStartedAt: Moment, parameters: PropTypes.arrayOf(PropTypes.object), + isEditing: PropTypes.bool, onParametersUpdate: PropTypes.func, + onParametersEdit: PropTypes.func, }; VisualizationWidgetHeader.defaultProps = { refreshStartedAt: null, onParametersUpdate: () => {}, + onParametersEdit: () => {}, + isEditing: false, parameters: [], }; @@ -190,6 +207,7 @@ class VisualizationWidget extends React.Component { isPublic: PropTypes.bool, isLoading: PropTypes.bool, canEdit: PropTypes.bool, + isEditing: PropTypes.bool, onLoad: PropTypes.func, onRefresh: PropTypes.func, onDelete: PropTypes.func, @@ -201,6 +219,7 @@ class VisualizationWidget extends React.Component { isPublic: false, isLoading: false, canEdit: false, + isEditing: false, onLoad: () => {}, onRefresh: () => {}, onDelete: () => {}, @@ -284,10 +303,15 @@ class VisualizationWidget extends React.Component { } render() { - const { widget, isLoading, isPublic, canEdit, onRefresh } = this.props; + const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props; const { localParameters } = this.state; const widgetQueryResult = widget.getQueryResult(); const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus()); + const onParametersEdit = parameters => { + const paramOrder = map(parameters, "name"); + widget.options.paramOrder = paramOrder; + widget.save("options", { paramOrder }); + }; return ( } footer={ diff --git a/client/app/lib/useQueryResultData.js b/client/app/lib/useQueryResultData.js index 3b19e1aad3..b7b87017fe 100644 --- a/client/app/lib/useQueryResultData.js +++ b/client/app/lib/useQueryResultData.js @@ -9,6 +9,7 @@ function getQueryResultData(queryResult, queryResultStatus = null) { filters: invoke(queryResult, "getFilters") || [], updatedAt: invoke(queryResult, "getUpdatedAt") || null, retrievedAt: get(queryResult, "query_result.retrieved_at", null), + truncated: invoke(queryResult, "getTruncated") || null, log: invoke(queryResult, "getLog") || [], error: invoke(queryResult, "getError") || null, runtime: invoke(queryResult, "getRuntime") || null, diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index a926991dc6..c3d12db29b 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -1,4 +1,4 @@ -import { isEmpty } from "lodash"; +import { isEmpty, map } from "lodash"; import React, { useState, useEffect } from "react"; import PropTypes from "prop-types"; import cx from "classnames"; @@ -25,8 +25,8 @@ import WaterMark from "@/lib/waterMark"; import "./DashboardPage.less"; -function DashboardSettings({ dashboardOptions }) { - const { dashboard, updateDashboard } = dashboardOptions; +function DashboardSettings({ dashboardConfiguration }) { + const { dashboard, updateDashboard } = dashboardConfiguration; return (

@@ -67,12 +67,12 @@ function AddWidgetContainer({ dashboardOptions, className, ...props }) { } AddWidgetContainer.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types className: PropTypes.string, }; function DashboardComponent(props) { - const dashboardOptions = useDashboard(props.dashboard); + const dashboardConfiguration = useDashboard(props.dashboard); const { dashboard, filters, @@ -82,14 +82,19 @@ function DashboardComponent(props) { removeWidget, saveDashboardLayout, globalParameters, + updateDashboard, refreshDashboard, refreshWidget, editingLayout, setGridDisabled, - } = dashboardOptions; + } = dashboardConfiguration; const [pageContainer, setPageContainer] = useState(null); const [bottomPanelStyles, setBottomPanelStyles] = useState({}); + const onParametersEdit = parameters => { + const paramOrder = map(parameters, "name"); + updateDashboard({ options: { globalParamOrder: paramOrder } }); + }; useEffect(() => { if (pageContainer) { @@ -115,14 +120,23 @@ function DashboardComponent(props) { return (
+ } /> {!isEmpty(globalParameters) && (
- +
)} {!isEmpty(filters) && ( @@ -130,7 +144,7 @@ function DashboardComponent(props) {
)} - {editingLayout && } + {editingLayout && }
- {editingLayout && } + {editingLayout && ( + + )}

); } diff --git a/client/app/pages/dashboards/components/DashboardHeader.jsx b/client/app/pages/dashboards/components/DashboardHeader.jsx index 3170321023..869ebc2348 100644 --- a/client/app/pages/dashboards/components/DashboardHeader.jsx +++ b/client/app/pages/dashboards/components/DashboardHeader.jsx @@ -27,8 +27,8 @@ function buttonType(value) { return value ? "primary" : "default"; } -function DashboardPageTitle({ dashboardOptions }) { - const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions; +function DashboardPageTitle({ dashboardConfiguration }) { + const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration; return (
@@ -58,11 +58,11 @@ function DashboardPageTitle({ dashboardOptions }) { } DashboardPageTitle.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; -function RefreshButton({ dashboardOptions }) { - const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions; +function RefreshButton({ dashboardConfiguration }) { + const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration; const allowedIntervals = policy.getDashboardRefreshIntervals(); const refreshRateOptions = clientConfig.dashboardRefreshIntervals; const onRefreshRateSelected = ({ key }) => { @@ -105,10 +105,10 @@ function RefreshButton({ dashboardOptions }) { } RefreshButton.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; -function DashboardMoreOptionsButton({ dashboardOptions }) { +function DashboardMoreOptionsButton({ dashboardConfiguration }) { const { dashboard, setEditingLayout, @@ -117,7 +117,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { managePermissions, gridDisabled, isDashboardOwnerOrAdmin, - } = dashboardOptions; + } = dashboardConfiguration; const archive = () => { Modal.confirm({ @@ -163,10 +163,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { } DashboardMoreOptionsButton.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; -function DashboardControl({ dashboardOptions, headerExtra }) { +function DashboardControl({ dashboardConfiguration, headerExtra }) { const { dashboard, togglePublished, @@ -174,7 +174,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) { fullscreen, toggleFullscreen, showShareDashboardDialog, - } = dashboardOptions; + } = dashboardConfiguration; const showPublishButton = dashboard.is_draft; const showRefreshButton = true; const showFullscreenButton = !dashboard.is_draft; @@ -190,7 +190,7 @@ function DashboardControl({ dashboardOptions, headerExtra }) { Publish )} - {showRefreshButton && } + {showRefreshButton && } {showFullscreenButton && ( )} - {showMoreOptionsButton && } + {showMoreOptionsButton && } )}
@@ -218,12 +218,17 @@ function DashboardControl({ dashboardOptions, headerExtra }) { } DashboardControl.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types headerExtra: PropTypes.node, }; -function DashboardEditControl({ dashboardOptions, headerExtra }) { - const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions; +function DashboardEditControl({ dashboardConfiguration, headerExtra }) { + const { + setEditingLayout, + doneBtnClickedWhileSaving, + dashboardStatus, + retrySaveDashboardLayout, + } = dashboardConfiguration; let status; if (dashboardStatus === DashboardStatusEnum.SAVED) { status = Saved; @@ -258,23 +263,23 @@ function DashboardEditControl({ dashboardOptions, headerExtra }) { } DashboardEditControl.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types headerExtra: PropTypes.node, }; -export default function DashboardHeader({ dashboardOptions, headerExtra }) { - const { editingLayout } = dashboardOptions; +export default function DashboardHeader({ dashboardConfiguration, headerExtra }) { + const { editingLayout } = dashboardConfiguration; const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl; return (
- - + +
); } DashboardHeader.propTypes = { - dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types headerExtra: PropTypes.node, }; diff --git a/client/app/pages/queries/QuerySource.jsx b/client/app/pages/queries/QuerySource.jsx index 1486c02784..774b720963 100644 --- a/client/app/pages/queries/QuerySource.jsx +++ b/client/app/pages/queries/QuerySource.jsx @@ -336,6 +336,7 @@ function QuerySource(props) {
updateParametersDirtyFlag()} diff --git a/client/app/pages/queries/components/QueryExecutionMetadata.jsx b/client/app/pages/queries/components/QueryExecutionMetadata.jsx index 5b8496ba1e..f793482bc3 100644 --- a/client/app/pages/queries/components/QueryExecutionMetadata.jsx +++ b/client/app/pages/queries/components/QueryExecutionMetadata.jsx @@ -1,6 +1,8 @@ import React from "react"; import PropTypes from "prop-types"; +import WarningTwoTone from "@ant-design/icons/WarningTwoTone"; import TimeAgo from "@/components/TimeAgo"; +import Tooltip from "antd/lib/tooltip"; import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog"; import useEmbedDialog from "../hooks/useEmbedDialog"; import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown"; @@ -42,6 +44,18 @@ export default function QueryExecutionMetadata({ )} + {queryResultData.truncated === true && ( + + + + + + )} {queryResultData.rows.length} {pluralize("row", queryResultData.rows.length)} diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 261be9085e..76f1eb096a 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -208,12 +208,19 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() { }); } }); - return _.values( + const resultingGlobalParams = _.values( _.each(globalParams, param => { param.setValue(param.value); // apply global param value to all locals param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) }) ); + + // order dashboard params using paramOrder + return _.sortBy(resultingGlobalParams, param => + _.includes(this.options.globalParamOrder, param.name) + ? _.indexOf(this.options.globalParamOrder, param.name) + : _.size(this.options.globalParamOrder) + ); }; Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) { diff --git a/client/app/services/query-result.js b/client/app/services/query-result.js index cba8d5effc..4b2892aebc 100644 --- a/client/app/services/query-result.js +++ b/client/app/services/query-result.js @@ -272,6 +272,10 @@ class QueryResult { return this.getColumnNames().map(col => getColumnFriendlyName(col)); } + getTruncated() { + return this.query_result.data ? this.query_result.data.truncated : null; + } + getFilters() { if (!this.getColumns()) { return []; diff --git a/client/app/services/widget.js b/client/app/services/widget.js index 16dcc4199f..1fc5d124db 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -1,6 +1,21 @@ import moment from "moment"; import { axios } from "@/services/axios"; -import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from "lodash"; +import { + each, + pick, + extend, + isObject, + truncate, + keys, + difference, + filter, + map, + merge, + sortBy, + indexOf, + size, + includes, +} from "lodash"; import location from "@/services/location"; import { cloneParameter } from "@/services/parameters"; import dashboardGridOptions from "@/config/dashboard-grid-options"; @@ -207,7 +222,7 @@ class Widget { const queryParams = location.search; const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue]; - return map( + const localParameters = map( filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0), param => { const mapping = mappings[param.name]; @@ -223,6 +238,13 @@ class Widget { return result; } ); + + // order widget params using paramOrder + return sortBy(localParameters, param => + includes(this.options.paramOrder, param.name) + ? indexOf(this.options.paramOrder, param.name) + : size(this.options.paramOrder) + ); } getParameterMappings() { diff --git a/client/cypress/integration/dashboard/parameter_mapping_spec.js b/client/cypress/integration/dashboard/parameter_mapping_spec.js deleted file mode 100644 index ba1e13fa86..0000000000 --- a/client/cypress/integration/dashboard/parameter_mapping_spec.js +++ /dev/null @@ -1,111 +0,0 @@ -import { createQueryAndAddWidget } from "../../support/dashboard"; - -describe("Parameter Mapping", () => { - beforeEach(function() { - cy.login(); - cy.createDashboard("Foo Bar") - .then(({ id }) => { - this.dashboardId = id; - this.dashboardUrl = `/dashboards/${id}`; - }) - .then(() => { - const queryData = { - name: "Text Parameter", - query: "SELECT '{{test-parameter}}' AS parameter", - options: { - parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text", value: "example" }], - }, - }; - const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } }; - createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => { - cy.visit(this.dashboardUrl); - this.widgetTestId = widgetTestId; - }); - }); - }); - - const openMappingOptions = (widgetTestId, paramName) => { - cy.getByTestId(widgetTestId).within(() => { - cy.getByTestId("WidgetDropdownButton").click(); - }); - - cy.getByTestId("WidgetDropdownButtonMenu") - .contains("Edit Parameters") - .click(); - - cy.getByTestId(`EditParamMappingButton-${paramName}`).click(); - }; - - const saveMappingOptions = () => { - cy.getByTestId("EditParamMappingPopover").within(() => { - cy.contains("button", "OK").click(); - }); - - cy.contains("button", "OK").click(); - }; - - it("supports widget parameters", function() { - // widget parameter mapping is the default for the API - cy.getByTestId(this.widgetTestId).within(() => { - cy.getByTestId("TableVisualization").should("contain", "example"); - - cy.getByTestId("ParameterName-test-parameter") - .find("input") - .type("{selectall}Redash"); - - cy.getByTestId("ParameterApplyButton").click(); - - cy.getByTestId("TableVisualization").should("contain", "Redash"); - }); - - cy.getByTestId("DashboardParameters").should("not.exist"); - }); - - it("supports dashboard parameters", function() { - openMappingOptions(this.widgetTestId, "test-parameter"); - - cy.getByTestId("NewDashboardParameterOption").click(); - - saveMappingOptions(); - - cy.getByTestId(this.widgetTestId).within(() => { - cy.getByTestId("ParameterName-test-parameter").should("not.exist"); - }); - - cy.getByTestId("DashboardParameters").within(() => { - cy.getByTestId("ParameterName-test-parameter") - .find("input") - .type("{selectall}DashboardParam"); - - cy.getByTestId("ParameterApplyButton").click(); - }); - - cy.getByTestId(this.widgetTestId).within(() => { - cy.getByTestId("TableVisualization").should("contain", "DashboardParam"); - }); - }); - - it("supports static values for parameters", function() { - openMappingOptions(this.widgetTestId, "test-parameter"); - - cy.getByTestId("StaticValueOption").click(); - - cy.getByTestId("EditParamMappingPopover").within(() => { - cy.getByTestId("ParameterValueInput") - .find("input") - .type("{selectall}StaticValue"); - }); - - saveMappingOptions(); - - cy.getByTestId(this.widgetTestId).within(() => { - cy.getByTestId("ParameterName-test-parameter").should("not.exist"); - }); - - cy.getByTestId("DashboardParameters").should("not.exist"); - - cy.getByTestId(this.widgetTestId).within(() => { - cy.getByTestId("TableVisualization").should("contain", "StaticValue"); - }); - }); -}); diff --git a/client/cypress/integration/dashboard/parameter_spec.js b/client/cypress/integration/dashboard/parameter_spec.js new file mode 100644 index 0000000000..9abac3b111 --- /dev/null +++ b/client/cypress/integration/dashboard/parameter_spec.js @@ -0,0 +1,160 @@ +import { each } from "lodash"; +import { createQueryAndAddWidget, editDashboard } from "../../support/dashboard"; +import { dragParam, expectParamOrder } from "../../support/parameters"; + +describe("Dashboard Parameters", () => { + const parameters = [ + { name: "param1", title: "Parameter 1", type: "text", value: "example1" }, + { name: "param2", title: "Parameter 2", type: "text", value: "example2" }, + ]; + + beforeEach(function() { + cy.login(); + cy.createDashboard("Foo Bar") + .then(({ id }) => { + this.dashboardId = id; + this.dashboardUrl = `/dashboards/${id}`; + }) + .then(() => { + const queryData = { + name: "Text Parameter", + query: "SELECT '{{param1}}', '{{param2}}' AS parameter", + options: { + parameters, + }, + }; + const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } }; + createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => { + cy.visit(this.dashboardUrl); + this.widgetTestId = widgetTestId; + }); + }); + }); + + const openMappingOptions = widgetTestId => { + cy.getByTestId(widgetTestId).within(() => { + cy.getByTestId("WidgetDropdownButton").click(); + }); + + cy.getByTestId("WidgetDropdownButtonMenu") + .contains("Edit Parameters") + .click(); + }; + + const saveMappingOptions = (close = true) => { + cy.getByTestId("EditParamMappingPopover") + .filter(":visible") + .within(() => { + cy.contains("button", "OK").click(); + }); + + cy.getByTestId("EditParamMappingPopover").should("not.be.visible"); + + if (close) { + cy.contains("button", "OK").click(); + } + }; + + const setWidgetParametersToDashboard = parameters => { + each(parameters, ({ name: paramName }, i) => { + cy.getByTestId(`EditParamMappingButton-${paramName}`).click(); + cy.getByTestId("NewDashboardParameterOption") + .filter(":visible") + .click(); + saveMappingOptions(i === parameters.length - 1); + }); + }; + + it("supports widget parameters", function() { + // widget parameter mapping is the default for the API + cy.getByTestId(this.widgetTestId).within(() => { + cy.getByTestId("TableVisualization").should("contain", "example1"); + + cy.getByTestId("ParameterName-param1") + .find("input") + .type("{selectall}Redash"); + + cy.getByTestId("ParameterApplyButton").click(); + + cy.getByTestId("TableVisualization").should("contain", "Redash"); + }); + + cy.getByTestId("DashboardParameters").should("not.exist"); + }); + + it("supports dashboard parameters", function() { + openMappingOptions(this.widgetTestId); + setWidgetParametersToDashboard(parameters); + + cy.getByTestId(this.widgetTestId).within(() => { + cy.getByTestId("ParameterName-param1").should("not.exist"); + }); + + cy.getByTestId("DashboardParameters").within(() => { + cy.getByTestId("ParameterName-param1") + .find("input") + .type("{selectall}DashboardParam"); + + cy.getByTestId("ParameterApplyButton").click(); + }); + + cy.getByTestId(this.widgetTestId).within(() => { + cy.getByTestId("TableVisualization").should("contain", "DashboardParam"); + }); + }); + + it("supports static values for parameters", function() { + openMappingOptions(this.widgetTestId); + cy.getByTestId("EditParamMappingButton-param1").click(); + + cy.getByTestId("StaticValueOption").click(); + + cy.getByTestId("EditParamMappingPopover").within(() => { + cy.getByTestId("ParameterValueInput") + .find("input") + .type("{selectall}StaticValue"); + }); + + saveMappingOptions(true); + + cy.getByTestId(this.widgetTestId).within(() => { + cy.getByTestId("ParameterName-param1").should("not.exist"); + }); + + cy.getByTestId("DashboardParameters").should("not.exist"); + + cy.getByTestId(this.widgetTestId).within(() => { + cy.getByTestId("TableVisualization").should("contain", "StaticValue"); + }); + }); + + it("reorders parameters", function() { + // Reorder is only available in edit mode + editDashboard(); + + const [param1, param2] = parameters; + + cy.getByTestId("ParameterBlock-param1") + .invoke("width") + .then(paramWidth => { + cy.server(); + cy.route("POST", `**/api/dashboards/*`).as("SaveDashboard"); + cy.route("POST", `**/api/widgets/*`).as("SaveWidget"); + + // Asserts widget param order + dragParam(param1.name, paramWidth, 1); + cy.wait("@SaveWidget"); + cy.reload(); + expectParamOrder([param2.title, param1.title]); + + // Asserts dashboard param order + openMappingOptions(this.widgetTestId); + setWidgetParametersToDashboard(parameters); + cy.wait("@SaveWidget"); + dragParam(param1.name, paramWidth, 1); + cy.wait("@SaveDashboard"); + cy.reload(); + expectParamOrder([param2.title, param1.title]); + }); + }); +}); diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js index ec11bb7bae..0b4cc9f60c 100644 --- a/client/cypress/integration/query/parameter_spec.js +++ b/client/cypress/integration/query/parameter_spec.js @@ -1,3 +1,11 @@ +import { dragParam } from "../../support/parameters"; + +function openAndSearchAntdDropdown(testId, paramOption) { + cy.getByTestId(testId) + .find(".ant-select-selection-search-input") + .type(paramOption, { force: true }); +} + describe("Parameter", () => { const expectDirtyStateChange = edit => { cy.getByTestId("ParameterName-test-parameter") @@ -107,11 +115,13 @@ describe("Parameter", () => { }); it("updates the results after selecting a value", () => { - cy.getByTestId("ParameterName-test-parameter") - .find(".ant-select") - .click(); + openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop - cy.contains(".ant-select-item-option", "value2").click(); + // only the filtered option should be on the DOM + cy.get(".ant-select-item-option") + .should("have.length", 1) + .and("contain", "value2") + .click(); cy.getByTestId("ParameterApplyButton").click(); // ensure that query is being executed @@ -219,6 +229,22 @@ describe("Parameter", () => { }); }); + it("updates the results after selecting a value", () => { + openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop + + // only the filtered option should be on the DOM + cy.get(".ant-select-item-option") + .should("have.length", 1) + .and("contain", "value2") + .click(); + + cy.getByTestId("ParameterApplyButton").click(); + // ensure that query is being executed + cy.getByTestId("QueryExecutionStatus").should("exist"); + + cy.getByTestId("TableVisualization").should("contain", "2"); + }); + it("supports multi-selection", () => { cy.clickThrough(` ParameterSettings-test-parameter @@ -575,16 +601,6 @@ describe("Parameter", () => { cy.get("body").type("{alt}D"); // hide schema browser }); - const dragParam = (paramName, offsetLeft, offsetTop) => { - cy.getByTestId(`DragHandle-${paramName}`) - .trigger("mouseover") - .trigger("mousedown"); - - cy.get(".parameter-dragged .drag-handle") - .trigger("mousemove", offsetLeft, offsetTop, { force: true }) - .trigger("mouseup", { force: true }); - }; - it("is possible to rearrange parameters", function() { cy.server(); cy.route("POST", "**/api/queries/*").as("QuerySave"); diff --git a/client/cypress/integration/visualizations/chart_spec.js b/client/cypress/integration/visualizations/chart_spec.js new file mode 100644 index 0000000000..830023ce56 --- /dev/null +++ b/client/cypress/integration/visualizations/chart_spec.js @@ -0,0 +1,110 @@ +/* global cy */ + +import { getWidgetTestId } from "../../support/dashboard"; +import { + assertAxesAndAddLabels, + assertPlotPreview, + assertTabbedEditor, + createChartThroughUI, + createDashboardWithCharts, +} from "../../support/visualizations/chart"; + +const SQL = ` + SELECT 'a' AS stage, 11 AS value1, 22 AS value2 UNION ALL + SELECT 'a' AS stage, 12 AS value1, 41 AS value2 UNION ALL + SELECT 'a' AS stage, 45 AS value1, 93 AS value2 UNION ALL + SELECT 'a' AS stage, 54 AS value1, 79 AS value2 UNION ALL + SELECT 'b' AS stage, 33 AS value1, 65 AS value2 UNION ALL + SELECT 'b' AS stage, 73 AS value1, 50 AS value2 UNION ALL + SELECT 'b' AS stage, 90 AS value1, 40 AS value2 UNION ALL + SELECT 'c' AS stage, 19 AS value1, 33 AS value2 UNION ALL + SELECT 'c' AS stage, 92 AS value1, 14 AS value2 UNION ALL + SELECT 'c' AS stage, 63 AS value1, 65 AS value2 UNION ALL + SELECT 'c' AS stage, 44 AS value1, 27 AS value2\ +`; + +describe("Chart", () => { + beforeEach(() => { + cy.login(); + cy.createQuery({ name: "Chart Visualization", query: SQL }) + .its("id") + .as("queryId"); + }); + + it("creates Bar charts", function() { + cy.visit(`queries/${this.queryId}/source`); + cy.getByTestId("ExecuteButton").click(); + + const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => { + // checks for TabbedEditor standard tabs + assertTabbedEditor(); + + // standard chart should be bar + cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar"); + + // checks the plot canvas exists and is empty + assertPlotPreview("not.exist"); + + // creates a chart and checks it is plotted + cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2"); + assertPlotPreview("exist"); + + specificBarChartAssertionFn(); + }; + + const chartTests = [ + { + name: "Basic Bar Chart", + alias: "basicBarChart", + assertionFn: () => { + assertAxesAndAddLabels("Stage", "Value"); + }, + }, + { + name: "Horizontal Bar Chart", + alias: "horizontalBarChart", + assertionFn: () => { + cy.getByTestId("Chart.SwappedAxes").check(); + cy.getByTestId("VisualizationEditor.Tabs.XAxis").should("have.text", "Y Axis"); + cy.getByTestId("VisualizationEditor.Tabs.YAxis").should("have.text", "X Axis"); + }, + }, + { + name: "Stacked Bar Chart", + alias: "stackedBarChart", + assertionFn: () => { + cy.getByTestId("Chart.Stacking").selectAntdOption("Chart.Stacking.Stack"); + }, + }, + { + name: "Normalized Bar Chart", + alias: "normalizedBarChart", + assertionFn: () => { + cy.getByTestId("Chart.NormalizeValues").check(); + }, + }, + ]; + + chartTests.forEach(({ name, alias, assertionFn }) => { + createChartThroughUI(name, getBarChartAssertionFunction(assertionFn)).as(alias); + }); + + const chartGetters = chartTests.map(({ alias }) => alias); + + const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => { + cy.visit(dashboardUrl); + widgetGetters.forEach(widgetGetter => { + cy.get(`@${widgetGetter}`).then(widget => { + cy.getByTestId(getWidgetTestId(widget)).within(() => { + cy.get("g.points").should("exist"); + }); + }); + }); + }; + + createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn); + cy.percySnapshot("Visualizations - Charts - Bar"); + }); +}); diff --git a/client/cypress/support/parameters.js b/client/cypress/support/parameters.js new file mode 100644 index 0000000000..335cc70f7d --- /dev/null +++ b/client/cypress/support/parameters.js @@ -0,0 +1,13 @@ +export function dragParam(paramName, offsetLeft, offsetTop) { + cy.getByTestId(`DragHandle-${paramName}`) + .trigger("mouseover") + .trigger("mousedown"); + + cy.get(".parameter-dragged .drag-handle") + .trigger("mousemove", offsetLeft, offsetTop, { force: true }) + .trigger("mouseup", { force: true }); +} + +export function expectParamOrder(expectedOrder) { + cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index])); +} diff --git a/client/cypress/support/visualizations/chart.js b/client/cypress/support/visualizations/chart.js new file mode 100644 index 0000000000..a18375daee --- /dev/null +++ b/client/cypress/support/visualizations/chart.js @@ -0,0 +1,100 @@ +/** + * Asserts the preview canvas exists, then captures the g.points element, which should be generated by plotly and asserts whether it exists + * @param should Passed to should expression after plot points are captured + */ +export function assertPlotPreview(should = "exist") { + cy.getByTestId("VisualizationPreview") + .find("g.plot") + .should("exist") + .find("g.points") + .should(should); +} + +export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) { + cy.getByTestId("NewVisualization").click(); + cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART"); + cy.getByTestId("VisualizationName") + .clear() + .type(chartName); + + chartSpecificAssertionFn(); + + cy.server(); + cy.route("POST", "**/api/visualizations").as("SaveVisualization"); + + cy.getByTestId("EditVisualizationDialog") + .contains("button", "Save") + .click(); + + cy.getByTestId("QueryPageVisualizationTabs") + .contains("span", chartName) + .should("exist"); + + cy.wait("@SaveVisualization").should("have.property", "status", 200); + + return cy.get("@SaveVisualization").then(xhr => { + const { id, name, options } = xhr.response.body; + return cy.wrap({ id, name, options }); + }); +} + +export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () => {}) { + cy.getByTestId("Chart.GlobalSeriesType").should("exist"); + + cy.getByTestId("VisualizationEditor.Tabs.Series").click(); + cy.getByTestId("VisualizationEditor") + .find("table") + .should("exist"); + + cy.getByTestId("VisualizationEditor.Tabs.Colors").click(); + cy.getByTestId("VisualizationEditor") + .find("table") + .should("exist"); + + cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click(); + cy.getByTestId("VisualizationEditor") + .getByTestId("Chart.DataLabels.ShowDataLabels") + .should("exist"); + + chartSpecificTabbedEditorAssertionFn(); + + cy.getByTestId("VisualizationEditor.Tabs.General").click(); +} + +export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) { + cy.getByTestId("VisualizationEditor.Tabs.XAxis").click(); + cy.getByTestId("Chart.XAxis.Type") + .contains(".ant-select-selection-item", "Auto Detect") + .should("exist"); + + cy.getByTestId("Chart.XAxis.Name") + .clear() + .type(xaxisLabel); + + cy.getByTestId("VisualizationEditor.Tabs.YAxis").click(); + cy.getByTestId("Chart.LeftYAxis.Type") + .contains(".ant-select-selection-item", "Linear") + .should("exist"); + + cy.getByTestId("Chart.LeftYAxis.Name") + .clear() + .type(yaxisLabel); + + cy.getByTestId("VisualizationEditor.Tabs.General").click(); +} + +export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) { + cy.createDashboard(title).then(dashboard => { + const dashboardUrl = `/dashboards/${dashboard.id}`; + const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`); + + chartGetters.forEach((chartGetter, i) => { + const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 }; + cy.get(`@${chartGetter}`) + .then(chart => cy.addWidget(dashboard.id, chart.id, { position })) + .as(widgetGetters[i]); + }); + + widgetsAssertionFn(widgetGetters, dashboardUrl); + }); +} diff --git a/migrations/versions/0ec979123ba4_.py b/migrations/versions/0ec979123ba4_.py new file mode 100644 index 0000000000..4dfbe1ba15 --- /dev/null +++ b/migrations/versions/0ec979123ba4_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 0ec979123ba4 +Revises: e5c7a4e2df4d +Create Date: 2020-12-23 21:35:32.766354 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0ec979123ba4' +down_revision = 'e5c7a4e2df4d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('dashboards', 'options') + # ### end Alembic commands ### diff --git a/redash/destinations/chatwork.py b/redash/destinations/chatwork.py index d3dda8288a..b3ea726608 100644 --- a/redash/destinations/chatwork.py +++ b/redash/destinations/chatwork.py @@ -22,6 +22,7 @@ def configuration_schema(cls): "title": "Message Template", }, }, + "secret": ["api_token"], "required": ["message_template", "api_token", "room_id"], } diff --git a/redash/destinations/hangoutschat.py b/redash/destinations/hangoutschat.py index bc52f3de69..e896913b2a 100644 --- a/redash/destinations/hangoutschat.py +++ b/redash/destinations/hangoutschat.py @@ -28,6 +28,7 @@ def configuration_schema(cls): "title": "Icon URL (32x32 or multiple, png format)", }, }, + "secret": ["url"], "required": ["url"], } diff --git a/redash/destinations/hipchat.py b/redash/destinations/hipchat.py index add7ee1a60..a8bd8c882f 100644 --- a/redash/destinations/hipchat.py +++ b/redash/destinations/hipchat.py @@ -25,6 +25,7 @@ def configuration_schema(cls): "title": "HipChat Notification URL (get it from the Integrations page)", } }, + "secret": ["url"], "required": ["url"], } diff --git a/redash/destinations/mattermost.py b/redash/destinations/mattermost.py index 5d601ff6ff..106e1184ef 100644 --- a/redash/destinations/mattermost.py +++ b/redash/destinations/mattermost.py @@ -16,6 +16,7 @@ def configuration_schema(cls): "icon_url": {"type": "string", "title": "Icon (URL)"}, "channel": {"type": "string", "title": "Channel"}, }, + "secret": "url" } @classmethod diff --git a/redash/destinations/pagerduty.py b/redash/destinations/pagerduty.py index 3a844fa10d..9ffb5fbc65 100644 --- a/redash/destinations/pagerduty.py +++ b/redash/destinations/pagerduty.py @@ -32,6 +32,7 @@ def configuration_schema(cls): "title": "Description for the event, defaults to alert name", }, }, + "secret": ["integration_key"], "required": ["integration_key"], } diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py index edbd6f2c2f..a5fd834283 100644 --- a/redash/destinations/slack.py +++ b/redash/destinations/slack.py @@ -17,6 +17,7 @@ def configuration_schema(cls): "icon_url": {"type": "string", "title": "Icon (URL)"}, "channel": {"type": "string", "title": "Channel"}, }, + "secret": ["url"] } @classmethod diff --git a/redash/destinations/webhook.py b/redash/destinations/webhook.py index 83581e9e37..ad5ccb1e9a 100644 --- a/redash/destinations/webhook.py +++ b/redash/destinations/webhook.py @@ -18,7 +18,7 @@ def configuration_schema(cls): "password": {"type": "string"}, }, "required": ["url"], - "secret": ["password"], + "secret": ["password", "url"], } @classmethod diff --git a/redash/handlers/dashboards.py b/redash/handlers/dashboards.py index 6ec4898066..5e900cd693 100644 --- a/redash/handlers/dashboards.py +++ b/redash/handlers/dashboards.py @@ -137,6 +137,7 @@ def get(self, dashboard_id=None): :>json boolean is_draft: Whether this dashboard is a draft or not. :>json array layout: Array of arrays containing widget IDs, corresponding to the rows and columns the widgets are displayed in :>json array widgets: Array of arrays containing :ref:`widget ` data + :>json object options: Dashboard options .. _widget-response-label: @@ -209,6 +210,7 @@ def post(self, dashboard_id): "is_draft", "is_archived", "dashboard_filters_enabled", + "options", ), ) diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 0672fc5d7f..0cb2de3d6d 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -1141,6 +1141,9 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model tags = Column( "tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True ) + options = Column( + MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={} + ) __tablename__ = "dashboards" __mapper_args__ = {"version_id_col": version} @@ -1174,7 +1177,6 @@ def all(cls, org, user): ), Dashboard.org == org, ) - .distinct() ) query = query.filter( diff --git a/redash/query_runner/databricks.py b/redash/query_runner/databricks.py index 2ce2cd9b51..5cc98ce17f 100644 --- a/redash/query_runner/databricks.py +++ b/redash/query_runner/databricks.py @@ -1,4 +1,6 @@ import datetime +import logging +import os import sqlparse from redash.query_runner import ( NotSupported, @@ -11,8 +13,9 @@ TYPE_INTEGER, TYPE_FLOAT, ) +from redash.settings import cast_int_or_default from redash.utils import json_dumps, json_loads -from redash import __version__ +from redash import __version__, settings, statsd_client try: import pyodbc @@ -30,6 +33,9 @@ float: TYPE_FLOAT, } +ROW_LIMIT = cast_int_or_default(os.environ.get("DATABRICKS_ROW_LIMIT"), 20000) + +logger = logging.getLogger(__name__) def _build_odbc_connection_string(**kwargs): return ";".join([f"{k}={v}" for k, v in kwargs.items()]) @@ -40,8 +46,10 @@ def strip_trailing_comments(stmt): idx = len(stmt.tokens) - 1 while idx >= 0: tok = stmt.tokens[idx] - if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment): - stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ') + if tok.is_whitespace or sqlparse.utils.imt( + tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment + ): + stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ") else: break idx -= 1 @@ -53,8 +61,13 @@ def strip_trailing_semicolon(stmt): tok = stmt.tokens[idx] # we expect that trailing comments already are removed if not tok.is_whitespace: - if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";": - stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, ' ') + if ( + sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) + and tok.value == ";" + ): + stmt.tokens[idx] = sqlparse.sql.Token( + sqlparse.tokens.Whitespace, " " + ) break idx -= 1 return stmt @@ -74,7 +87,11 @@ def is_empty_statement(stmt): result = [stmt for stmt in stack.run(query)] result = [strip_trailing_comments(stmt) for stmt in result] result = [strip_trailing_semicolon(stmt) for stmt in result] - result = [sqlparse.text_type(stmt).strip() for stmt in result if not is_empty_statement(stmt)] + result = [ + sqlparse.text_type(stmt).strip() + for stmt in result + if not is_empty_statement(stmt) + ] if len(result) > 0: return result @@ -147,7 +164,7 @@ def run_query(self, query, user): cursor.execute(stmt) if cursor.description is not None: - data = cursor.fetchall() + result_set = cursor.fetchmany(ROW_LIMIT) columns = self.fetch_columns( [ (i[0], TYPES_MAP.get(i[1], TYPE_STRING)) @@ -157,10 +174,18 @@ def run_query(self, query, user): rows = [ dict(zip((column["name"] for column in columns), row)) - for row in data + for row in result_set ] data = {"columns": columns, "rows": rows} + + if ( + len(result_set) >= ROW_LIMIT + and cursor.fetchone() is not None + ): + logger.warning("Truncated result set.") + statsd_client.incr("redash.query_runner.databricks.truncated") + data["truncated"] = True json_data = json_dumps(data) error = None else: diff --git a/redash/query_runner/mongodb.py b/redash/query_runner/mongodb.py index b89e2b6640..f9ed2130f5 100644 --- a/redash/query_runner/mongodb.py +++ b/redash/query_runner/mongodb.py @@ -133,6 +133,8 @@ def configuration_schema(cls): "type": "object", "properties": { "connectionString": {"type": "string", "title": "Connection String"}, + "username": {"type": "string"}, + "password": {"type": "string"}, "dbName": {"type": "string", "title": "Database Name"}, "replicaSetName": {"type": "string", "title": "Replica Set Name"}, "readPreference": { @@ -147,6 +149,7 @@ def configuration_schema(cls): "title": "Replica Set Read Preference", }, }, + "secret": ["password"], "required": ["connectionString", "dbName"], } @@ -176,6 +179,12 @@ def _get_db(self): if readPreference: kwargs["readPreference"] = readPreference + if "username" in self.configuration: + kwargs["username"] = self.configuration["username"] + + if "password" in self.configuration: + kwargs["password"] = self.configuration["password"] + db_connection = pymongo.MongoClient( self.configuration["connectionString"], **kwargs ) diff --git a/redash/query_runner/pg.py b/redash/query_runner/pg.py index d1427ade63..6af812faeb 100644 --- a/redash/query_runner/pg.py +++ b/redash/query_runner/pg.py @@ -169,7 +169,7 @@ def configuration_schema(cls): }, "order": ["host", "port", "user", "password"], "required": ["dbname"], - "secret": ["password"], + "secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"], "extra_options": [ "sslmode", "sslrootcertFile", diff --git a/redash/query_runner/treasuredata.py b/redash/query_runner/treasuredata.py index 763b3e3c4a..3e53b136ce 100644 --- a/redash/query_runner/treasuredata.py +++ b/redash/query_runner/treasuredata.py @@ -53,6 +53,7 @@ def configuration_schema(cls): "default": False, }, }, + "secret": ["apikey"], "required": ["apikey", "db"], } diff --git a/redash/query_runner/yandex_metrica.py b/redash/query_runner/yandex_metrica.py index b263c5e345..1802525e73 100644 --- a/redash/query_runner/yandex_metrica.py +++ b/redash/query_runner/yandex_metrica.py @@ -89,6 +89,7 @@ def configuration_schema(cls): return { "type": "object", "properties": {"token": {"type": "string", "title": "OAuth Token"}}, + "secret": ["token"], "required": ["token"], } diff --git a/redash/serializers/__init__.py b/redash/serializers/__init__.py index 83b1fa266b..6105364c49 100644 --- a/redash/serializers/__init__.py +++ b/redash/serializers/__init__.py @@ -55,7 +55,7 @@ def public_widget(widget): def public_dashboard(dashboard): dashboard_dict = project( serialize_dashboard(dashboard, with_favorite_state=False), - ("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at"), + ("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at", "options"), ) widget_list = ( @@ -257,6 +257,7 @@ def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state= "layout": layout, "dashboard_filters_enabled": obj.dashboard_filters_enabled, "widgets": widgets, + "options": obj.options, "is_archived": obj.is_archived, "is_draft": obj.is_draft, "tags": obj.tags or [], diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index bab961ebeb..00d48ec763 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -11,6 +11,7 @@ int_or_none, set_from_string, add_decode_responses_to_redis_url, + cast_int_or_default ) from .organization import DATE_FORMAT, TIME_FORMAT # noqa @@ -312,7 +313,7 @@ def email_server_is_configured(): THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour") LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL) -# CORS settings for the Query Result API (and possbily future external APIs). +# CORS settings for the Query Result API (and possibly future external APIs). # In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN # to the calling domain (or domains in a comma separated list). ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string( @@ -519,4 +520,6 @@ def email_server_is_configured(): os.environ.get("REDASH_ENFORCE_CSRF", "false") ) +# Databricks + CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6)) diff --git a/redash/settings/helpers.py b/redash/settings/helpers.py index c69f326f98..3fe95eecaa 100644 --- a/redash/settings/helpers.py +++ b/redash/settings/helpers.py @@ -29,6 +29,11 @@ def parse_boolean(s): else: raise ValueError("Invalid boolean value %r" % s) +def cast_int_or_default(val, default=None): + try: + return int(val) + except (ValueError, TypeError): + return default def int_or_none(value): if value is None: diff --git a/tests/query_runner/test_mongodb.py b/tests/query_runner/test_mongodb.py index bb5d2ce5ea..f156e3f36b 100644 --- a/tests/query_runner/test_mongodb.py +++ b/tests/query_runner/test_mongodb.py @@ -1,10 +1,12 @@ import datetime from unittest import TestCase +from mock import patch, call from pytz import utc from freezegun import freeze_time from redash.query_runner.mongodb import ( + MongoDB, parse_query_json, parse_results, _get_column_by_name, @@ -12,6 +14,33 @@ from redash.utils import json_dumps, parse_human_time +@patch("redash.query_runner.mongodb.pymongo.MongoClient") +class TestUserPassOverride(TestCase): + def test_username_password_present_overrides_username_from_uri(self, mongo_client): + config = { + "connectionString": "mongodb://localhost:27017/test", + "username": "test_user", + "password": "test_pass", + "dbName": "test" + } + mongo_qr = MongoDB(config) + _ = mongo_qr._get_db() + + self.assertIn("username", mongo_client.call_args.kwargs) + self.assertIn("password", mongo_client.call_args.kwargs) + + def test_username_password_absent_does_not_pass_args(self, mongo_client): + config = { + "connectionString": "mongodb://user:pass@localhost:27017/test", + "dbName": "test" + } + mongo_qr = MongoDB(config) + _ = mongo_qr._get_db() + + self.assertNotIn("username", mongo_client.call_args.kwargs) + self.assertNotIn("password", mongo_client.call_args.kwargs) + + class TestParseQueryJson(TestCase): def test_ignores_non_isodate_fields(self): query = {"test": 1, "test_list": ["a", "b", "c"], "test_dict": {"a": 1, "b": 2}} diff --git a/viz-lib/src/components/sortable/index.tsx b/viz-lib/src/components/sortable/index.tsx index e0dc569ed0..2e329cec08 100644 --- a/viz-lib/src/components/sortable/index.tsx +++ b/viz-lib/src/components/sortable/index.tsx @@ -6,26 +6,19 @@ import { sortableContainer, sortableElement, sortableHandle } from "react-sortab import "./style.less"; -export const DragHandle = sortableHandle(({ - className, - ...restProps -}: any) => ( +export const DragHandle = sortableHandle(({ className, ...restProps }: any) => (
)); -export const SortableContainerWrapper = sortableContainer(({ - children -}: any) => children); +export const SortableContainerWrapper = sortableContainer(({ children }: any) => children); -export const SortableElement = sortableElement(({ - children -}: any) => children); +export const SortableElement = sortableElement(({ children }: any) => children); type OwnProps = { - disabled?: boolean; - containerComponent?: React.ReactElement; - containerProps?: any; - children?: React.ReactNode; + disabled?: boolean; + containerComponent?: React.ReactElement; + containerProps?: any; + children?: React.ReactNode; }; type Props = OwnProps & typeof SortableContainer.defaultProps; @@ -47,7 +40,7 @@ export function SortableContainer({ disabled, containerComponent, containerProps // Enabled state: // - use container element as a default helper element - // @ts-expect-error ts-migrate(2339) FIXME: Property 'helperContainer' does not exist on type ... Remove this comment to see the full error message + // @ts-expect-error wrapperProps.helperContainer = wrap(wrapperProps.helperContainer, helperContainer => isFunction(helperContainer) ? helperContainer(containerRef.current) : containerRef.current ); @@ -60,6 +53,16 @@ export function SortableContainer({ disabled, containerComponent, containerProps updateBeforeSortStart(...args); } }); + // @ts-expect-error + wrapperProps.onSortStart = wrap(wrapperProps.onSortStart, (onSortStart, ...args) => { + if (isFunction(onSortStart)) { + onSortStart(...args); + } else { + const event = args[1] as DragEvent; + event.preventDefault(); + } + }); + // @ts-expect-error ts-migrate(2339) FIXME: Property 'onSortEnd' does not exist on type '{}'. wrapperProps.onSortEnd = wrap(wrapperProps.onSortEnd, (onSortEnd, ...args) => { setIsDragging(false); diff --git a/viz-lib/src/visualizations/map/getOptions.ts b/viz-lib/src/visualizations/map/getOptions.ts index b8dc804fbd..20a93324ad 100644 --- a/viz-lib/src/visualizations/map/getOptions.ts +++ b/viz-lib/src/visualizations/map/getOptions.ts @@ -1,6 +1,31 @@ import { merge } from "lodash"; -const DEFAULT_OPTIONS = { +export type LeafletBaseIconType = "marker" | "rectangle" | "circle" | "rectangle-dot" | "circle-dot" | "doughnut"; +export interface MapOptionsType { + latColName: string; + lonColName: string; + classify: any; + groups: Record; + mapTileUrl: string; + clusterMarkers: boolean; + customizeMarkers: boolean; + iconShape: LeafletBaseIconType; + iconFont: LeafletBaseIconType; + foregroundColor: string; + backgroundColor: string; + borderColor: string; + bounds: any; + tooltip: { + enabled: boolean; + template: string; + }; + popup: { + enabled: boolean; + template: string; + }; +} + +const DEFAULT_OPTIONS: MapOptionsType = { latColName: "lat", lonColName: "lon", classify: null, @@ -24,7 +49,7 @@ const DEFAULT_OPTIONS = { }, }; -export default function getOptions(options: any) { +export default function getOptions(options: MapOptionsType) { options = merge({}, DEFAULT_OPTIONS, options); options.mapTileUrl = options.mapTileUrl || DEFAULT_OPTIONS.mapTileUrl; diff --git a/viz-lib/src/visualizations/sankey/Renderer.tsx b/viz-lib/src/visualizations/sankey/Renderer.tsx index 9d9e2cbf17..235fec159f 100644 --- a/viz-lib/src/visualizations/sankey/Renderer.tsx +++ b/viz-lib/src/visualizations/sankey/Renderer.tsx @@ -2,15 +2,14 @@ import React, { useState, useEffect, useMemo } from "react"; import resizeObserver from "@/services/resizeObserver"; import { RendererPropTypes } from "@/visualizations/prop-types"; -import initSankey from "./initSankey"; +import { SankeyDataType } from "./index"; +import initSankey, { ExtendedSankeyDataType } from "./initSankey"; import "./renderer.less"; -export default function Renderer({ - data -}: any) { - const [container, setContainer] = useState(null); +export default function Renderer({ data }: { data: SankeyDataType }) { + const [container, setContainer] = useState(null); - const render = useMemo(() => initSankey(data), [data]); + const render = useMemo(() => initSankey(data as ExtendedSankeyDataType), [data]); useEffect(() => { if (container) { @@ -22,7 +21,6 @@ export default function Renderer({ } }, [container, render]); - // @ts-expect-error ts-migrate(2322) FIXME: Type 'Dispatch>' is not assig... Remove this comment to see the full error message return
; } diff --git a/viz-lib/src/visualizations/sankey/d3sankey.ts b/viz-lib/src/visualizations/sankey/d3sankey.ts index 6fc05ddb55..d3d544d905 100644 --- a/viz-lib/src/visualizations/sankey/d3sankey.ts +++ b/viz-lib/src/visualizations/sankey/d3sankey.ts @@ -2,6 +2,38 @@ import d3 from "d3"; +export interface LinkType { + id: number; + name: string; + color: string; + x: number; + y: number; + dx: number; + dy: number; + source: SourceTargetType; + target: SourceTargetType; +} + +export type SourceTargetType = { + sourceLinks: Array; + targetLinks: Array; +}; + +export type NodeType = LinkType & SourceTargetType; +export interface D3SankeyType { + nodeWidth: (...args: any[]) => any; + nodeHeight: (...args: any[]) => any; + nodePadding: (...args: any[]) => any; + nodes: (...args: any[]) => any[]; + link: (...args: any[]) => any; + links: (...args: any[]) => any[]; + size: (...args: any[]) => any; + layout: (...args: any[]) => any; + relayout: (...args: any[]) => any; +} + +export type DType = { sy: number; ty: number; value: number; source: LinkType; target: LinkType } & LinkType; + function center(node: any) { return node.y + node.dy / 2; } @@ -10,23 +42,21 @@ function value(link: any) { return link.value; } -function Sankey() { +function Sankey(): D3SankeyType { const sankey = {}; let nodeWidth = 24; let nodePadding = 8; let size = [1, 1]; - let nodes: any = []; - let links: any = []; + let nodes: any[] = []; + let links: any[] = []; // Populate the sourceLinks and targetLinks for each node. // Also, if the source and target are not objects, assume they are indices. function computeNodeLinks() { - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { node.sourceLinks = []; node.targetLinks = []; }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'link' implicitly has an 'any' type. links.forEach(link => { let source = link.source; let target = link.target; @@ -39,14 +69,12 @@ function Sankey() { // Compute the value (size) of each node by summing the associated links. function computeNodeValues() { - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { node.value = Math.max(d3.sum(node.sourceLinks, value), d3.sum(node.targetLinks, value)); }); } function moveSinksRight(x: any) { - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { if (!node.sourceLinks.length) { node.x = x - 1; @@ -55,7 +83,6 @@ function Sankey() { } function scaleNodeBreadths(kx: any) { - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { node.x *= kx; }); @@ -89,7 +116,6 @@ function Sankey() { moveSinksRight(x); x = Math.max( - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message d3.max(nodes, n => n.x), 2 ); // get new maximum x value (min 2) @@ -98,7 +124,7 @@ function Sankey() { function computeNodeDepths(iterations: any) { const nodesByBreadth = d3 - // @ts-expect-error ts-migrate(2339) FIXME: Property 'nest' does not exist on type 'typeof imp... Remove this comment to see the full error message + // @ts-expect-error .nest() .key((d: any) => d.x) .sortKeys(d3.ascending) @@ -117,7 +143,6 @@ function Sankey() { }); }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'link' implicitly has an 'any' type. links.forEach(link => { // @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. link.dy = link.value * ky; @@ -206,12 +231,10 @@ function Sankey() { } function computeLinkDepths() { - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { node.sourceLinks.sort(ascendingTargetDepth); node.targetLinks.sort(ascendingSourceDepth); }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. nodes.forEach(node => { let sy = 0, ty = 0; @@ -289,7 +312,7 @@ function Sankey() { sankey.link = function() { let curvature = 0.5; - function link(d: any) { + function link(d: DType) { const x0 = d.source.x + d.source.dx; const x1 = d.target.x; const xi = d3.interpolateNumber(x0, x1); @@ -310,7 +333,7 @@ function Sankey() { return link; }; - return sankey; + return sankey as D3SankeyType; } export default Sankey; diff --git a/viz-lib/src/visualizations/sankey/index.ts b/viz-lib/src/visualizations/sankey/index.ts index b744d83a88..965861d877 100644 --- a/viz-lib/src/visualizations/sankey/index.ts +++ b/viz-lib/src/visualizations/sankey/index.ts @@ -1,11 +1,23 @@ import Renderer from "./Renderer"; import Editor from "./Editor"; +export interface SankeyDataType { + columns: { + name: string; + friendly_name: string; + type: "integer"; + }[]; + + rows: { + value: number; + [name: string]: number | string | null; + }[]; +} export default { type: "SANKEY", name: "Sankey", - getOptions: (options: any) => ({ - ...options + getOptions: (options: {}) => ({ + ...options, }), Renderer, Editor, diff --git a/viz-lib/src/visualizations/sankey/initSankey.ts b/viz-lib/src/visualizations/sankey/initSankey.ts index 3a1e30fb62..b0d47b880d 100644 --- a/viz-lib/src/visualizations/sankey/initSankey.ts +++ b/viz-lib/src/visualizations/sankey/initSankey.ts @@ -1,26 +1,48 @@ -import { isNil, map, extend, sortBy, includes, filter, reduce, find, keys, values, identity } from "lodash"; +import { + isNil, + map, + extend, + sortBy, + includes, + filter, + reduce, + find, + keys, + values, + identity, + mapValues, + every, + isNaN, + isNumber, + isString, +} from "lodash"; import d3 from "d3"; -import d3sankey from "./d3sankey"; +import d3sankey, { NodeType, LinkType, SourceTargetType, DType } from "./d3sankey"; +import { SankeyDataType } from "."; -function getConnectedNodes(node: any) { +export type ExtendedSankeyDataType = Partial & { nodes: any[]; links: any[] }; + +function getConnectedNodes(node: NodeType) { + console.log(node); // source link = this node is the source, I need the targets const nodes: any = []; - node.sourceLinks.forEach((link: any) => { + node.sourceLinks.forEach((link: LinkType) => { nodes.push(link.target); }); - node.targetLinks.forEach((link: any) => { + node.targetLinks.forEach((link: LinkType) => { nodes.push(link.source); }); return nodes; } -function graph(data: any) { +function graph(data: ExtendedSankeyDataType["rows"]) { const nodesDict = {}; const links = {}; - const nodes: any = []; + const nodes: any[] = []; const validKey = (key: any) => key !== "value"; + // @ts-expect-error const dataKeys = sortBy(filter(keys(data[0]), validKey), identity); function normalizeName(name: any) { @@ -31,7 +53,7 @@ function graph(data: any) { return "Exit"; } - function getNode(name: any, level: any) { + function getNode(name: string, level: any) { name = normalizeName(name); const key = `${name}:${String(level)}`; // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message @@ -45,7 +67,7 @@ function graph(data: any) { return node; } - function getLink(source: any, target: any) { + function getLink(source: SourceTargetType, target: SourceTargetType) { // @ts-expect-error ts-migrate(2538) FIXME: Type 'any[]' cannot be used as an index type. let link = links[[source, target]]; if (!link) { @@ -68,6 +90,7 @@ function graph(data: any) { link.value += parseInt(value, 10); } + // @ts-expect-error data.forEach((row: any) => { addLink(row[dataKeys[0]], row[dataKeys[1]], row.value || 0, 1); addLink(row[dataKeys[1]], row[dataKeys[2]], row.value || 0, 2); @@ -85,13 +108,14 @@ function graph(data: any) { }; } -function spreadNodes(height: any, data: any) { +function spreadNodes(height: any, data: ExtendedSankeyDataType) { const nodesByBreadth = d3 // @ts-expect-error ts-migrate(2339) FIXME: Property 'nest' does not exist on type 'typeof imp... Remove this comment to see the full error message .nest() - .key((d: any) => d.x) + .key((d: DType) => d.x) .entries(data.nodes) - .map((d: any) => d.values); + // @ts-expect-error + .map((d: DType) => d.values); nodesByBreadth.forEach((nodes: any) => { nodes = filter( @@ -114,14 +138,39 @@ function spreadNodes(height: any, data: any) { }); } -function isDataValid(data: any) { +function isDataValid(data: ExtendedSankeyDataType) { // data should contain column named 'value', otherwise no reason to render anything at all - return data && !!find(data.columns, c => c.name === "value"); + if (!data || !find(data.columns, c => c.name === "value")) { + return false; + } + // prepareData will have coerced any invalid data rows into NaN, which is verified below + return every(data.rows, row => + every(row, v => { + if (!v || isString(v)) { + return true; + } + return isFinite(v); + }) + ); } -export default function initSankey(data: any) { +// will coerce number strings into valid numbers +function prepareDataRows(rows: ExtendedSankeyDataType["rows"]) { + return map(rows, row => + mapValues(row, v => { + if (!v || isNumber(v)) { + return v; + } + return isNaN(parseFloat(v)) ? v : parseFloat(v); + }) + ); +} + +export default function initSankey(data: ExtendedSankeyDataType) { + data.rows = prepareDataRows(data.rows) as ExtendedSankeyDataType["rows"]; + if (!isDataValid(data)) { - return (element: any) => { + return (element: HTMLDivElement) => { d3.select(element) .selectAll("*") .remove(); @@ -129,9 +178,10 @@ export default function initSankey(data: any) { } data = graph(data.rows); - const format = (d: any) => d3.format(",.0f")(d); // TODO: editor option ? + // @ts-expect-error + const format = (d: DType) => d3.format(",.0f")(d); // TODO: editor option ? - return (element: any) => { + return (element: HTMLDivElement) => { d3.select(element) .selectAll("*") .remove(); @@ -150,7 +200,7 @@ export default function initSankey(data: any) { } // append the svg canvas to the page - const svg = d3 + const svg: d3.Selection = d3 .select(element) .append("svg") .attr("class", "sankey") @@ -161,7 +211,6 @@ export default function initSankey(data: any) { // Set the sankey diagram properties const sankey = d3sankey() - // @ts-expect-error ts-migrate(2339) FIXME: Property 'nodeWidth' does not exist on type '{}'. .nodeWidth(15) .nodePadding(10) .size([width, height]); @@ -183,17 +232,13 @@ export default function initSankey(data: any) { .data(data.links) .enter() .append("path") - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. .filter(l => l.target.name !== "Exit") .attr("class", "link") .attr("d", path) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. .style("stroke-width", d => Math.max(1, d.dy)) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. .sort((a, b) => b.dy - a.dy); // add the link titles - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. link.append("title").text(d => `${d.source.name} → ${d.target.name}\n${format(d.value)}`); const node = svg @@ -202,13 +247,11 @@ export default function initSankey(data: any) { .data(data.nodes) .enter() .append("g") - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. .filter(n => n.name !== "Exit") .attr("class", "node") - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .attr("transform", d => `translate(${d.x},${d.y})`); + .attr("transform", (d: DType) => `translate(${d.x},${d.y})`); - function nodeMouseOver(currentNode: any) { + function nodeMouseOver(currentNode: NodeType) { let nodes = getConnectedNodes(currentNode); nodes = map(nodes, i => i.id); node @@ -216,7 +259,6 @@ export default function initSankey(data: any) { if (d === currentNode) { return false; } - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. return !includes(nodes, d.id); }) .style("opacity", 0.2); @@ -234,33 +276,27 @@ export default function initSankey(data: any) { node.on("mouseover", nodeMouseOver).on("mouseout", nodeMouseOut); // add the rectangles for the nodes - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. + // FIXME: d is DType, but d3 will not accept a nonstandard function node .append("rect") - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .attr("height", d => d.dy) + .attr("height", (d: any) => d.dy) .attr("width", sankey.nodeWidth()) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .style("fill", d => d.color) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .style("stroke", d => d3.rgb(d.color).darker(2)) + .style("fill", (d: any) => d.color) + // @ts-expect-error + .style("stroke", (d: any) => d3.rgb(d.color).darker(2)) .append("title") - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .text(d => `${d.name}\n${format(d.value)}`); + .text((d: any) => `${d.name}\n${format(d.value)}`); // add in the title for the nodes node .append("text") .attr("x", -6) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .attr("y", d => d.dy / 2) + .attr("y", (d: any) => d.dy / 2) .attr("dy", ".35em") .attr("text-anchor", "end") .attr("transform", null) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .text(d => d.name) - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. - .filter(d => d.x < width / 2) + .text((d: any) => d.name) + .filter((d: any) => d.x < width / 2) .attr("x", 6 + sankey.nodeWidth()) .attr("text-anchor", "start"); };