From df8be91a0707d7db03c6e0b28a42688aadd56295 Mon Sep 17 00:00:00 2001 From: Kamil Frydel Date: Wed, 9 Jul 2025 15:20:12 +0200 Subject: [PATCH 1/9] Add migration to set default alert selector (#7475) In commits fc1e1f7 and e44fcdb a new Selector option was added to alerts, which may be "first", "min" or "max". This migration sets the default to "first" for existing alerts. --- .../1655999df5e3_default_alert_selector.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 migrations/versions/1655999df5e3_default_alert_selector.py diff --git a/migrations/versions/1655999df5e3_default_alert_selector.py b/migrations/versions/1655999df5e3_default_alert_selector.py new file mode 100644 index 000000000..2bacedfb7 --- /dev/null +++ b/migrations/versions/1655999df5e3_default_alert_selector.py @@ -0,0 +1,26 @@ +"""set default alert selector + +Revision ID: 1655999df5e3 +Revises: 9e8c841d1a30 +Create Date: 2025-07-09 14:44:00 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '1655999df5e3' +down_revision = '9e8c841d1a30' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + UPDATE alerts + SET options = jsonb_set(options, '{selector}', '"first"') + WHERE options->>'selector' IS NULL; + """) + +def downgrade(): + pass From f3b0b60abdeadc46eb97c83cf34f573017169f5a Mon Sep 17 00:00:00 2001 From: Elliot Maincourt Date: Wed, 9 Jul 2025 18:09:24 +0200 Subject: [PATCH 2/9] feat(flask): make refresh cookie name configurable (#7473) --- redash/security.py | 1 + redash/settings/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/redash/security.py b/redash/security.py index f95c839fd..29d5d0c28 100644 --- a/redash/security.py +++ b/redash/security.py @@ -25,6 +25,7 @@ def init_app(app): app.config["WTF_CSRF_CHECK_DEFAULT"] = False app.config["WTF_CSRF_SSL_STRICT"] = False app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT + app.config["SESSION_COOKIE_NAME"] = settings.SESSION_COOKIE_NAME @app.after_request def inject_csrf_token(response): diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 973beffc5..5da10eac7 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -82,6 +82,7 @@ SESSION_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_SECU # Whether the session cookie is set HttpOnly. SESSION_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true")) SESSION_EXPIRY_TIME = int(os.environ.get("REDASH_SESSION_EXPIRY_TIME", 60 * 60 * 6)) +SESSION_COOKIE_NAME = os.environ.get("REDASH_SESSION_COOKIE_NAME", "session") # Whether the session cookie is set to secure. REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE)) From d8ae6799376c2f391672a1765eed9cca1e4930d4 Mon Sep 17 00:00:00 2001 From: Tsuneo Yoshioka Date: Wed, 16 Jul 2025 09:48:36 +0900 Subject: [PATCH 3/9] Make NULL values visible (#7439) * Make NULL value visible * Make the representation of NULL value configurable * use display-as-null css class for null-value styling --- .../visualizationComponents.jsx | 1 + redash/handlers/authentication.py | 7 ++++ redash/settings/organization.py | 2 ++ viz-lib/src/lib/value-format.tsx | 35 ++++++++++++++++--- .../ColumnsSettings.test.tsx.snap | 5 +++ .../visualizations/table/columns/number.tsx | 2 +- .../src/visualizations/table/getOptions.ts | 1 + .../src/visualizations/table/renderer.less | 5 +++ .../visualizations/visualizationsSettings.tsx | 1 + 9 files changed, 53 insertions(+), 6 deletions(-) diff --git a/client/app/components/visualizations/visualizationComponents.jsx b/client/app/components/visualizations/visualizationComponents.jsx index 5eb05bed0..fcfc2926e 100644 --- a/client/app/components/visualizations/visualizationComponents.jsx +++ b/client/app/components/visualizations/visualizationComponents.jsx @@ -59,6 +59,7 @@ function wrapComponentWithSettings(WrappedComponent) { "dateTimeFormat", "integerFormat", "floatFormat", + "nullValue", "booleanValues", "tableCellMaxJSONSize", "allowCustomJSVisualizations", diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py index 62b20531c..23bff14cf 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -255,6 +255,12 @@ def number_format_config(): } +def null_value_config(): + return { + "nullValue": current_org.get_setting("null_value"), + } + + def client_config(): if not current_user.is_api_user() and current_user.is_authenticated: client_config = { @@ -289,6 +295,7 @@ def client_config(): client_config.update({"basePath": base_href()}) client_config.update(date_time_format_config()) client_config.update(number_format_config()) + client_config.update(null_value_config()) return client_config diff --git a/redash/settings/organization.py b/redash/settings/organization.py index 87a2269a8..4f6de8aac 100644 --- a/redash/settings/organization.py +++ b/redash/settings/organization.py @@ -27,6 +27,7 @@ DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY") TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm") INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0") FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00") +NULL_VALUE = os.environ.get("REDASH_NULL_VALUE", "null") MULTI_BYTE_SEARCH_ENABLED = parse_boolean(os.environ.get("MULTI_BYTE_SEARCH_ENABLED", "false")) JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false")) @@ -59,6 +60,7 @@ settings = { "time_format": TIME_FORMAT, "integer_format": INTEGER_FORMAT, "float_format": FLOAT_FORMAT, + "null_value": NULL_VALUE, "multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED, "auth_jwt_login_enabled": JWT_LOGIN_ENABLED, "auth_jwt_auth_issuer": JWT_AUTH_ISSUER, diff --git a/viz-lib/src/lib/value-format.tsx b/viz-lib/src/lib/value-format.tsx index 96693d2d3..5b85f8849 100644 --- a/viz-lib/src/lib/value-format.tsx +++ b/viz-lib/src/lib/value-format.tsx @@ -5,6 +5,7 @@ import numeral from "numeral"; import { isString, isArray, isUndefined, isFinite, isNil, toString } from "lodash"; import { visualizationsSettings } from "@/visualizations/visualizationsSettings"; + numeral.options.scalePercentBy100 = false; // eslint-disable-next-line @@ -12,9 +13,16 @@ const urlPattern = /(^|[\s\n]|)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019 const hasOwnProperty = Object.prototype.hasOwnProperty; +function NullValueComponent() { + return {visualizationsSettings.nullValue}; +} + export function createTextFormatter(highlightLinks: any) { if (highlightLinks) { return (value: any) => { + if (value === null) { + return + } if (isString(value)) { const Link = visualizationsSettings.LinkComponent; value = value.replace(urlPattern, (unused, prefix, href) => { @@ -29,7 +37,7 @@ export function createTextFormatter(highlightLinks: any) { return toString(value); }; } - return (value: any) => toString(value); + return (value: any) => value === null ? : toString(value); } function toMoment(value: any) { @@ -46,11 +54,14 @@ function toMoment(value: any) { export function createDateTimeFormatter(format: any) { if (isString(format) && format !== "") { return (value: any) => { + if (value === null) { + return ; + } const wrapped = toMoment(value); return wrapped.isValid() ? wrapped.format(format) : toString(value); }; } - return (value: any) => toString(value); + return (value: any) => value === null ? : toString(value); } export function createBooleanFormatter(values: any) { @@ -58,6 +69,9 @@ export function createBooleanFormatter(values: any) { if (values.length >= 2) { // Both `true` and `false` specified return (value: any) => { + if (value === null) { + return ; + } if (isNil(value)) { return ""; } @@ -69,6 +83,9 @@ export function createBooleanFormatter(values: any) { } } return (value: any) => { + if (value === null) { + return ; + } if (isNil(value)) { return ""; } @@ -76,12 +93,20 @@ export function createBooleanFormatter(values: any) { }; } -export function createNumberFormatter(format: any) { +export function createNumberFormatter(format: any, canReturnHTMLElement: boolean = false) { if (isString(format) && format !== "") { const n = numeral(0); // cache `numeral` instance - return (value: any) => (value === null || value === "" ? "" : n.set(value).format(format)); + return (value: any) => { + if (canReturnHTMLElement && value === null) { + return ; + } + if (value === "" || value === null) { + return ""; + } + return n.set(value).format(format); + } } - return (value: any) => toString(value); + return (value: any) => (canReturnHTMLElement && value === null) ? : toString(value); } export function formatSimpleTemplate(str: any, data: any) { diff --git a/viz-lib/src/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.tsx.snap b/viz-lib/src/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.tsx.snap index 9cd0c926e..9d07ca3ff 100644 --- a/viz-lib/src/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.tsx.snap +++ b/viz-lib/src/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.tsx.snap @@ -23,6 +23,7 @@ Object { "linkTitleTemplate": "{{ @ }}", "linkUrlTemplate": "{{ @ }}", "name": "a", + "nullValue": "null", "numberFormat": undefined, "order": 100000, "title": "a", @@ -56,6 +57,7 @@ Object { "linkTitleTemplate": "{{ @ }}", "linkUrlTemplate": "{{ @ }}", "name": "a", + "nullValue": "null", "numberFormat": undefined, "order": 100000, "title": "a", @@ -89,6 +91,7 @@ Object { "linkTitleTemplate": "{{ @ }}", "linkUrlTemplate": "{{ @ }}", "name": "a", + "nullValue": "null", "numberFormat": undefined, "order": 100000, "title": "test", @@ -122,6 +125,7 @@ Object { "linkTitleTemplate": "{{ @ }}", "linkUrlTemplate": "{{ @ }}", "name": "a", + "nullValue": "null", "numberFormat": undefined, "order": 100000, "title": "a", @@ -155,6 +159,7 @@ Object { "linkTitleTemplate": "{{ @ }}", "linkUrlTemplate": "{{ @ }}", "name": "a", + "nullValue": "null", "numberFormat": undefined, "order": 100000, "title": "a", diff --git a/viz-lib/src/visualizations/table/columns/number.tsx b/viz-lib/src/visualizations/table/columns/number.tsx index 78a8e8f41..f46ad01fa 100644 --- a/viz-lib/src/visualizations/table/columns/number.tsx +++ b/viz-lib/src/visualizations/table/columns/number.tsx @@ -33,7 +33,7 @@ function Editor({ column, onChange }: Props) { } export default function initNumberColumn(column: any) { - const format = createNumberFormatter(column.numberFormat); + const format = createNumberFormatter(column.numberFormat, true); function prepareData(row: any) { return { diff --git a/viz-lib/src/visualizations/table/getOptions.ts b/viz-lib/src/visualizations/table/getOptions.ts index a6b45ec57..bab412b05 100644 --- a/viz-lib/src/visualizations/table/getOptions.ts +++ b/viz-lib/src/visualizations/table/getOptions.ts @@ -73,6 +73,7 @@ function getDefaultFormatOptions(column: any) { 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: "{{ @ }}", diff --git a/viz-lib/src/visualizations/table/renderer.less b/viz-lib/src/visualizations/table/renderer.less index 1c538037a..af6044097 100644 --- a/viz-lib/src/visualizations/table/renderer.less +++ b/viz-lib/src/visualizations/table/renderer.less @@ -39,6 +39,11 @@ white-space: nowrap; } + .display-as-null { + font-style: italic; + color: @text-muted; + } + .table-visualization-spacer { padding-left: 0; padding-right: 0; diff --git a/viz-lib/src/visualizations/visualizationsSettings.tsx b/viz-lib/src/visualizations/visualizationsSettings.tsx index c3ded5b40..6e25ee32d 100644 --- a/viz-lib/src/visualizations/visualizationsSettings.tsx +++ b/viz-lib/src/visualizations/visualizationsSettings.tsx @@ -42,6 +42,7 @@ export const visualizationsSettings = { dateTimeFormat: "DD/MM/YYYY HH:mm", integerFormat: "0,0", floatFormat: "0,0.00", + nullValue: "null", booleanValues: ["false", "true"], tableCellMaxJSONSize: 50000, allowCustomJSVisualizations: false, From 9f76fda18caee513365ef5fcf47e40148ddfea4a Mon Sep 17 00:00:00 2001 From: Eric Radman Date: Tue, 15 Jul 2025 21:24:21 -0400 Subject: [PATCH 4/9] Use 12-column layout for dashboard grid (#7396) * Use 12-column layout for dashboard grid Set minSizeX, minSizeY for widgets to 2 since a value of 1 breaks all assumptions of the UI layout. Migration provide transition from 6 to 12 columns for all widgets. * Restyled by prettier --- .../components/dashboards/dashboard-grid.less | 2 +- client/app/config/dashboard-grid-options.js | 10 ++-- .../integration/dashboard/dashboard_spec.js | 43 +++++++---------- .../dashboard/grid_compliant_widgets_spec.js | 30 ++++++------ .../integration/dashboard/textbox_spec.js | 48 ++++++++----------- ...db0aca1ebd32_12_column_dashboard_layout.py | 34 +++++++++++++ viz-lib/src/visualizations/chart/index.ts | 2 +- .../src/visualizations/choropleth/index.ts | 2 +- viz-lib/src/visualizations/counter/index.ts | 2 +- viz-lib/src/visualizations/details/index.ts | 2 +- viz-lib/src/visualizations/map/index.ts | 2 +- viz-lib/src/visualizations/pivot/index.ts | 2 +- viz-lib/src/visualizations/table/index.ts | 2 +- 13 files changed, 98 insertions(+), 83 deletions(-) create mode 100644 migrations/versions/db0aca1ebd32_12_column_dashboard_layout.py diff --git a/client/app/components/dashboards/dashboard-grid.less b/client/app/components/dashboards/dashboard-grid.less index 1f5d0b39b..62467d639 100644 --- a/client/app/components/dashboards/dashboard-grid.less +++ b/client/app/components/dashboards/dashboard-grid.less @@ -51,7 +51,7 @@ right: 0; background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); - background-size: calc((100% + 15px) / 6) 5px; + background-size: calc((100% + 15px) / 12) 5px; background-position: -7px 1px; } } diff --git a/client/app/config/dashboard-grid-options.js b/client/app/config/dashboard-grid-options.js index a07c5691b..74b217335 100644 --- a/client/app/config/dashboard-grid-options.js +++ b/client/app/config/dashboard-grid-options.js @@ -1,13 +1,13 @@ export default { - columns: 6, // grid columns count + columns: 12, // grid columns count rowHeight: 50, // grid row height (incl. bottom padding) margins: 15, // widget margins mobileBreakPoint: 800, // defaults for widgets - defaultSizeX: 3, + defaultSizeX: 6, defaultSizeY: 3, - minSizeX: 1, - maxSizeX: 6, - minSizeY: 1, + minSizeX: 2, + maxSizeX: 12, + minSizeY: 2, maxSizeY: 1000, }; diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js index 7d5fff9db..9ff55a15a 100644 --- a/client/cypress/integration/dashboard/dashboard_spec.js +++ b/client/cypress/integration/dashboard/dashboard_spec.js @@ -23,7 +23,7 @@ describe("Dashboard", () => { cy.getByTestId("DashboardSaveButton").click(); }); - cy.wait("@NewDashboard").then(xhr => { + cy.wait("@NewDashboard").then((xhr) => { const id = Cypress._.get(xhr, "response.body.id"); assert.isDefined(id, "Dashboard api call returns id"); @@ -40,13 +40,9 @@ describe("Dashboard", () => { cy.getByTestId("DashboardMoreButton").click(); - cy.getByTestId("DashboardMoreButtonMenu") - .contains("Archive") - .click(); + cy.getByTestId("DashboardMoreButtonMenu").contains("Archive").click(); - cy.get(".ant-modal .ant-btn") - .contains("Archive") - .click({ force: true }); + cy.get(".ant-modal .ant-btn").contains("Archive").click({ force: true }); cy.get(".label-tag-archived").should("exist"); cy.visit("/dashboards"); @@ -60,7 +56,7 @@ describe("Dashboard", () => { cy.server(); cy.route("GET", "**/api/dashboards/*").as("LoadDashboard"); cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => { - [`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach(url => { + [`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach((url) => { cy.visit(url); cy.wait("@LoadDashboard"); cy.getByTestId(`DashboardId${id}Container`).should("exist"); @@ -72,7 +68,7 @@ describe("Dashboard", () => { }); context("viewport width is at 800px", () => { - before(function() { + before(function () { cy.login(); cy.createDashboard("Foo Bar") .then(({ id }) => { @@ -80,49 +76,42 @@ describe("Dashboard", () => { this.dashboardEditUrl = `/dashboards/${id}?edit`; return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); }) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); cy.getByTestId(elTestId).as("textboxEl"); }); }); - beforeEach(function() { + beforeEach(function () { cy.login(); cy.visit(this.dashboardUrl); cy.viewport(800 + menuWidth, 800); }); it("shows widgets with full width", () => { - cy.get("@textboxEl").should($el => { + cy.get("@textboxEl").should(($el) => { expect($el.width()).to.eq(770); }); cy.viewport(801 + menuWidth, 800); - cy.get("@textboxEl").should($el => { - expect($el.width()).to.eq(378); + cy.get("@textboxEl").should(($el) => { + expect($el.width()).to.eq(182); }); }); it("hides edit option", () => { - cy.getByTestId("DashboardMoreButton") - .click() - .should("be.visible"); + cy.getByTestId("DashboardMoreButton").click().should("be.visible"); - cy.getByTestId("DashboardMoreButtonMenu") - .contains("Edit") - .as("editButton") - .should("not.be.visible"); + cy.getByTestId("DashboardMoreButtonMenu").contains("Edit").as("editButton").should("not.be.visible"); cy.viewport(801 + menuWidth, 800); cy.get("@editButton").should("be.visible"); }); - it("disables edit mode", function() { + it("disables edit mode", function () { cy.viewport(801 + menuWidth, 800); cy.visit(this.dashboardEditUrl); - cy.contains("button", "Done Editing") - .as("saveButton") - .should("exist"); + cy.contains("button", "Done Editing").as("saveButton").should("exist"); cy.viewport(800 + menuWidth, 800); cy.contains("button", "Done Editing").should("not.exist"); @@ -130,14 +119,14 @@ describe("Dashboard", () => { }); context("viewport width is at 767px", () => { - before(function() { + before(function () { cy.login(); cy.createDashboard("Foo Bar").then(({ id }) => { this.dashboardUrl = `/dashboards/${id}`; }); }); - beforeEach(function() { + beforeEach(function () { cy.visit(this.dashboardUrl); cy.viewport(767, 800); }); diff --git a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js index 19883a017..be497b8dc 100644 --- a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js +++ b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js @@ -5,7 +5,7 @@ import { getWidgetTestId, editDashboard, resizeBy } from "../../support/dashboar const menuWidth = 80; describe("Grid compliant widgets", () => { - beforeEach(function() { + beforeEach(function () { cy.login(); cy.viewport(1215 + menuWidth, 800); cy.createDashboard("Foo Bar") @@ -13,7 +13,7 @@ describe("Grid compliant widgets", () => { this.dashboardUrl = `/dashboards/${id}`; return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); }) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); cy.getByTestId(elTestId).as("textboxEl"); }); @@ -27,7 +27,7 @@ describe("Grid compliant widgets", () => { it("stays put when dragged under snap threshold", () => { cy.get("@textboxEl") - .dragBy(90) + .dragBy(30) .invoke("offset") .should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15 }); @@ -36,14 +36,14 @@ describe("Grid compliant widgets", () => { cy.get("@textboxEl") .dragBy(110) .invoke("offset") - .should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215 + .should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115 }); it("moves two columns when dragged over snap threshold", () => { cy.get("@textboxEl") - .dragBy(330) + .dragBy(200) .invoke("offset") - .should("have.property", "left", 415 + menuWidth); // moved by 400, 15 -> 415 + .should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215 }); }); @@ -52,7 +52,7 @@ describe("Grid compliant widgets", () => { cy.route("POST", "**/api/widgets/*").as("WidgetSave"); editDashboard(); - cy.get("@textboxEl").dragBy(330); + cy.get("@textboxEl").dragBy(100); cy.wait("@WidgetSave"); }); }); @@ -64,24 +64,24 @@ describe("Grid compliant widgets", () => { }); it("stays put when dragged under snap threshold", () => { - resizeBy(cy.get("@textboxEl"), 90) + resizeBy(cy.get("@textboxEl"), 30) .then(() => cy.get("@textboxEl")) .invoke("width") - .should("eq", 585); // no change, 585 -> 585 + .should("eq", 285); // no change, 285 -> 285 }); it("moves one column when dragged over snap threshold", () => { resizeBy(cy.get("@textboxEl"), 110) .then(() => cy.get("@textboxEl")) .invoke("width") - .should("eq", 785); // resized by 200, 585 -> 785 + .should("eq", 385); // resized by 200, 185 -> 385 }); it("moves two columns when dragged over snap threshold", () => { resizeBy(cy.get("@textboxEl"), 400) .then(() => cy.get("@textboxEl")) .invoke("width") - .should("eq", 985); // resized by 400, 585 -> 985 + .should("eq", 685); // resized by 400, 285 -> 685 }); }); @@ -101,16 +101,16 @@ describe("Grid compliant widgets", () => { resizeBy(cy.get("@textboxEl"), 0, 30) .then(() => cy.get("@textboxEl")) .invoke("height") - .should("eq", 185); // resized by 50, , 135 -> 185 + .should("eq", 185); }); it("shrinks to minimum", () => { cy.get("@textboxEl") - .then($el => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0 + .then(($el) => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0 .then(() => cy.get("@textboxEl")) - .should($el => { + .should(($el) => { expect($el.width()).to.eq(185); // min textbox width - expect($el.height()).to.eq(35); // min textbox height + expect($el.height()).to.eq(85); // min textbox height }); }); }); diff --git a/client/cypress/integration/dashboard/textbox_spec.js b/client/cypress/integration/dashboard/textbox_spec.js index 669e73e91..006eeff4e 100644 --- a/client/cypress/integration/dashboard/textbox_spec.js +++ b/client/cypress/integration/dashboard/textbox_spec.js @@ -3,7 +3,7 @@ import { getWidgetTestId, editDashboard } from "../../support/dashboard"; describe("Textbox", () => { - beforeEach(function() { + beforeEach(function () { cy.login(); cy.createDashboard("Foo Bar").then(({ id }) => { this.dashboardId = id; @@ -12,12 +12,10 @@ describe("Textbox", () => { }); const confirmDeletionInModal = () => { - cy.get(".ant-modal .ant-btn") - .contains("Delete") - .click({ force: true }); + cy.get(".ant-modal .ant-btn").contains("Delete").click({ force: true }); }; - it("adds textbox", function() { + it("adds textbox", function () { cy.visit(this.dashboardUrl); editDashboard(); cy.getByTestId("AddTextboxButton").click(); @@ -29,10 +27,10 @@ describe("Textbox", () => { cy.get(".widget-text").should("exist"); }); - it("removes textbox by X button", function() { + it("removes textbox by X button", function () { cy.addTextbox(this.dashboardId, "Hello World!") .then(getWidgetTestId) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); editDashboard(); @@ -45,32 +43,30 @@ describe("Textbox", () => { }); }); - it("removes textbox by menu", function() { + it("removes textbox by menu", function () { cy.addTextbox(this.dashboardId, "Hello World!") .then(getWidgetTestId) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); cy.getByTestId(elTestId).within(() => { cy.getByTestId("WidgetDropdownButton").click(); }); - cy.getByTestId("WidgetDropdownButtonMenu") - .contains("Remove from Dashboard") - .click(); + cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click(); confirmDeletionInModal(); cy.getByTestId(elTestId).should("not.exist"); }); }); - it("allows opening menu after removal", function() { + it("allows opening menu after removal", function () { let elTestId1; cy.addTextbox(this.dashboardId, "txb 1") .then(getWidgetTestId) - .then(elTestId => { + .then((elTestId) => { elTestId1 = elTestId; return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId); }) - .then(elTestId2 => { + .then((elTestId2) => { cy.visit(this.dashboardUrl); editDashboard(); @@ -97,10 +93,10 @@ describe("Textbox", () => { }); }); - it("edits textbox", function() { + it("edits textbox", function () { cy.addTextbox(this.dashboardId, "Hello World!") .then(getWidgetTestId) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); cy.getByTestId(elTestId) .as("textboxEl") @@ -108,17 +104,13 @@ describe("Textbox", () => { cy.getByTestId("WidgetDropdownButton").click(); }); - cy.getByTestId("WidgetDropdownButtonMenu") - .contains("Edit") - .click(); + cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click(); const newContent = "[edited]"; cy.getByTestId("TextboxDialog") .should("exist") .within(() => { - cy.get("textarea") - .clear() - .type(newContent); + cy.get("textarea").clear().type(newContent); cy.contains("button", "Save").click(); }); @@ -126,7 +118,7 @@ describe("Textbox", () => { }); }); - it("renders textbox according to position configuration", function() { + it("renders textbox according to position configuration", function () { const id = this.dashboardId; const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 }; const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 }; @@ -135,15 +127,15 @@ describe("Textbox", () => { cy.addTextbox(id, "x", { position: txb1Pos }) .then(() => cy.addTextbox(id, "x", { position: txb2Pos })) .then(getWidgetTestId) - .then(elTestId => { + .then((elTestId) => { cy.visit(this.dashboardUrl); return cy.getByTestId(elTestId); }) - .should($el => { + .should(($el) => { const { top, left } = $el.offset(); expect(top).to.be.oneOf([162, 162.015625]); - expect(left).to.eq(282); - expect($el.width()).to.eq(545); + expect(left).to.eq(188); + expect($el.width()).to.eq(265); expect($el.height()).to.eq(185); }); }); diff --git a/migrations/versions/db0aca1ebd32_12_column_dashboard_layout.py b/migrations/versions/db0aca1ebd32_12_column_dashboard_layout.py new file mode 100644 index 000000000..06acd4a7d --- /dev/null +++ b/migrations/versions/db0aca1ebd32_12_column_dashboard_layout.py @@ -0,0 +1,34 @@ +"""12-column dashboard layout + +Revision ID: db0aca1ebd32 +Revises: 1655999df5e3 +Create Date: 2025-03-31 13:45:43.160893 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'db0aca1ebd32' +down_revision = '1655999df5e3' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + UPDATE widgets + SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int * 2)::jsonb); + UPDATE widgets + SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int * 2)::jsonb); + """) + + +def downgrade(): + op.execute(""" + UPDATE widgets + SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int / 2)::jsonb); + UPDATE widgets + SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int / 2)::jsonb); + """) diff --git a/viz-lib/src/visualizations/chart/index.ts b/viz-lib/src/visualizations/chart/index.ts index a787fa2e8..44ed3c98d 100644 --- a/viz-lib/src/visualizations/chart/index.ts +++ b/viz-lib/src/visualizations/chart/index.ts @@ -10,7 +10,7 @@ export default { Renderer, Editor, - defaultColumns: 3, + defaultColumns: 6, defaultRows: 8, minColumns: 1, minRows: 5, diff --git a/viz-lib/src/visualizations/choropleth/index.ts b/viz-lib/src/visualizations/choropleth/index.ts index 1b0f56929..aac457a1a 100644 --- a/viz-lib/src/visualizations/choropleth/index.ts +++ b/viz-lib/src/visualizations/choropleth/index.ts @@ -9,7 +9,7 @@ export default { Renderer, Editor, - defaultColumns: 3, + defaultColumns: 6, defaultRows: 8, minColumns: 2, }; diff --git a/viz-lib/src/visualizations/counter/index.ts b/viz-lib/src/visualizations/counter/index.ts index 21fa130cf..7a731223b 100644 --- a/viz-lib/src/visualizations/counter/index.ts +++ b/viz-lib/src/visualizations/counter/index.ts @@ -22,6 +22,6 @@ export default { Renderer, Editor, - defaultColumns: 2, + defaultColumns: 4, defaultRows: 5, }; diff --git a/viz-lib/src/visualizations/details/index.ts b/viz-lib/src/visualizations/details/index.ts index a8428ea67..aa321fd61 100644 --- a/viz-lib/src/visualizations/details/index.ts +++ b/viz-lib/src/visualizations/details/index.ts @@ -10,6 +10,6 @@ export default { ...options, }), Renderer: DetailsRenderer, - defaultColumns: 2, + defaultColumns: 4, defaultRows: 2, }; diff --git a/viz-lib/src/visualizations/map/index.ts b/viz-lib/src/visualizations/map/index.ts index d122c561e..32c67a40d 100644 --- a/viz-lib/src/visualizations/map/index.ts +++ b/viz-lib/src/visualizations/map/index.ts @@ -9,7 +9,7 @@ export default { Renderer, Editor, - defaultColumns: 3, + defaultColumns: 6, defaultRows: 8, minColumns: 2, }; diff --git a/viz-lib/src/visualizations/pivot/index.ts b/viz-lib/src/visualizations/pivot/index.ts index afba7151f..e9f25a6bb 100644 --- a/viz-lib/src/visualizations/pivot/index.ts +++ b/viz-lib/src/visualizations/pivot/index.ts @@ -23,6 +23,6 @@ export default { Editor, defaultRows: 10, - defaultColumns: 3, + defaultColumns: 6, minColumns: 2, }; diff --git a/viz-lib/src/visualizations/table/index.ts b/viz-lib/src/visualizations/table/index.ts index 6eadac9a6..7dc7158ba 100644 --- a/viz-lib/src/visualizations/table/index.ts +++ b/viz-lib/src/visualizations/table/index.ts @@ -11,6 +11,6 @@ export default { autoHeight: true, defaultRows: 14, - defaultColumns: 3, + defaultColumns: 6, minColumns: 2, }; From a34c1591e3b209d398cfdd4d1c569e7f0eb7ac48 Mon Sep 17 00:00:00 2001 From: Tsuneo Yoshioka Date: Fri, 18 Jul 2025 00:04:55 +0900 Subject: [PATCH 5/9] Upgrade prettier version to the same version that CI is using (#7367) --- package.json | 2 +- viz-lib/package.json | 2 +- viz-lib/yarn.lock | 8 ++++---- yarn.lock | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index df4c23a44..f53b9d421 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "mini-css-extract-plugin": "^1.6.2", "mockdate": "^2.0.2", "npm-run-all": "^4.1.5", - "prettier": "^1.19.1", + "prettier": "3.3.2", "raw-loader": "^0.5.1", "react-refresh": "^0.14.0", "react-test-renderer": "^16.14.0", diff --git a/viz-lib/package.json b/viz-lib/package.json index 42457ee66..9b86429aa 100644 --- a/viz-lib/package.json +++ b/viz-lib/package.json @@ -62,7 +62,7 @@ "less-loader": "^11.1.3", "less-plugin-autoprefix": "^2.0.0", "npm-run-all": "^4.1.5", - "prettier": "^1.19.1", + "prettier": "3.3.2", "prop-types": "^15.7.2", "style-loader": "^3.3.3", "ts-migrate": "^0.1.35", diff --git a/viz-lib/yarn.lock b/viz-lib/yarn.lock index 18f834b95..a9e6762d4 100644 --- a/viz-lib/yarn.lock +++ b/viz-lib/yarn.lock @@ -7723,10 +7723,10 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" - integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" + integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== pretty-format@^24.9.0: version "24.9.0" diff --git a/yarn.lock b/yarn.lock index d51905153..c81b457e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11156,10 +11156,10 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prettier@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" - integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" + integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== pretty-bytes@^5.6.0: version "5.6.0" From 3f781d262b623fc30f24b3fe72adaa1e16edc46d Mon Sep 17 00:00:00 2001 From: Eric Radman Date: Thu, 17 Jul 2025 14:50:13 -0400 Subject: [PATCH 6/9] Push by tag name for Docker repository "redash" (#7321) --- .github/workflows/preview-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-image.yml b/.github/workflows/preview-image.yml index 029cec854..24269d560 100644 --- a/.github/workflows/preview-image.yml +++ b/.github/workflows/preview-image.yml @@ -121,7 +121,7 @@ jobs: context: . build-args: | test_all_deps=true - outputs: type=image,push-by-digest=true,push=true + outputs: type=image,push-by-digest=false,push=true cache-from: type=gha,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }} env: From 5ae1f70d9ec76d27332921ab8093e36f4f63ab6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=81=D1=82=D1=8F=D1=82=D0=BD=D0=B8=D0=BD=20?= =?UTF-8?q?=D0=94=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D1=8C=D1=94=D0=B2?= <68417998+konstde00@users.noreply.github.com> Date: Sun, 20 Jul 2025 18:05:43 +0300 Subject: [PATCH 7/9] Add support for Google OAuth Scheme Override (#7178) * Added support for Google Oauth Scheme Override (through environment variable) * Refactoring * Refactoring * Applied formatting * Refactoring * Refactoring * Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable * Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable * Removed duplication of url_for function --------- Co-authored-by: kostiantyn-dementiev-op --- redash/authentication/google_oauth.py | 58 +++++++++++++++++++-------- redash/settings/__init__.py | 7 ++++ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/redash/authentication/google_oauth.py b/redash/authentication/google_oauth.py index 1107d4281..e9458792e 100644 --- a/redash/authentication/google_oauth.py +++ b/redash/authentication/google_oauth.py @@ -4,7 +4,7 @@ import requests from authlib.integrations.flask_client import OAuth from flask import Blueprint, flash, redirect, request, session, url_for -from redash import models +from redash import models, settings from redash.authentication import ( create_and_login_user, get_next_path, @@ -29,6 +29,41 @@ def verify_profile(org, profile): return False +def get_user_profile(access_token, logger): + headers = {"Authorization": f"OAuth {access_token}"} + response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers) + + if response.status_code == 401: + logger.warning("Failed getting user profile (response code 401).") + return None + + return response.json() + + +def build_redirect_uri(): + scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE or None + return url_for(".callback", _external=True, _scheme=scheme) + + +def build_next_path(org_slug=None): + next_path = request.args.get("next") + if not next_path: + if org_slug is None: + org_slug = session.get("org_slug") + + scheme = None + if settings.GOOGLE_OAUTH_SCHEME_OVERRIDE: + scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE + + next_path = url_for( + "redash.index", + org_slug=org_slug, + _external=True, + _scheme=scheme, + ) + return next_path + + def create_google_oauth_blueprint(app): oauth = OAuth(app) @@ -36,23 +71,12 @@ def create_google_oauth_blueprint(app): blueprint = Blueprint("google_oauth", __name__) CONF_URL = "https://accounts.google.com/.well-known/openid-configuration" - oauth = OAuth(app) oauth.register( name="google", server_metadata_url=CONF_URL, client_kwargs={"scope": "openid email profile"}, ) - def get_user_profile(access_token): - headers = {"Authorization": "OAuth {}".format(access_token)} - response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers) - - if response.status_code == 401: - logger.warning("Failed getting user profile (response code 401).") - return None - - return response.json() - @blueprint.route("//oauth/google", endpoint="authorize_org") def org_login(org_slug): session["org_slug"] = current_org.slug @@ -60,9 +84,9 @@ def create_google_oauth_blueprint(app): @blueprint.route("/oauth/google", endpoint="authorize") def login(): - redirect_uri = url_for(".callback", _external=True) + redirect_uri = build_redirect_uri() - next_path = request.args.get("next", url_for("redash.index", org_slug=session.get("org_slug"))) + next_path = build_next_path() logger.debug("Callback url: %s", redirect_uri) logger.debug("Next is: %s", next_path) @@ -86,7 +110,7 @@ def create_google_oauth_blueprint(app): flash("Validation error. Please retry.") return redirect(url_for("redash.login")) - profile = get_user_profile(access_token) + profile = get_user_profile(access_token, logger) if profile is None: flash("Validation error. Please retry.") return redirect(url_for("redash.login")) @@ -110,7 +134,9 @@ def create_google_oauth_blueprint(app): if user is None: return logout_and_redirect_to_index() - unsafe_next_path = session.get("next_url") or url_for("redash.index", org_slug=org.slug) + unsafe_next_path = session.get("next_url") + if not unsafe_next_path: + unsafe_next_path = build_next_path(org.slug) next_path = get_next_path(unsafe_next_path) return redirect(next_path) diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 5da10eac7..53fdfd340 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -136,6 +136,13 @@ FEATURE_POLICY = os.environ.get("REDASH_FEATURE_POLICY", "") MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false")) +# If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP +# even if your actual Redash URL scheme is HTTPS. This will cause Flask to build +# the OAuth redirect URL incorrectly thus failing auth. This is especially common if +# you're behind a SSL/TCP configured AWS ELB or similar. +# This setting will force the URL scheme. +GOOGLE_OAUTH_SCHEME_OVERRIDE = os.environ.get("REDASH_GOOGLE_OAUTH_SCHEME_OVERRIDE", "") + GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "") GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "") GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) From 4e200b4a0820153a3c4b801ccab5f77375c5d5d9 Mon Sep 17 00:00:00 2001 From: Lee2532 <43932570+Lee2532@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:45:37 +0900 Subject: [PATCH 8/9] bigquery load schema diff locations ignore (#7289) * diff locations ignore * add logging message * Processing Location is not specified --- redash/query_runner/big_query.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/redash/query_runner/big_query.py b/redash/query_runner/big_query.py index db36344c9..760760853 100644 --- a/redash/query_runner/big_query.py +++ b/redash/query_runner/big_query.py @@ -313,6 +313,10 @@ class BigQuery(BaseSQLQueryRunner): queries = [] for dataset in datasets: dataset_id = dataset["datasetReference"]["datasetId"] + location = dataset["location"] + if self._get_location() and location != self._get_location(): + logger.debug("dataset location is different: %s", location) + continue query = query_base.format(dataset_id=dataset_id) queries.append(query) From f5e2a4c0fc7cb5c214e3b918e2fc92d441eb5384 Mon Sep 17 00:00:00 2001 From: Eric Radman Date: Wed, 23 Jul 2025 11:34:26 -0400 Subject: [PATCH 9/9] Sort Dashboard and Query tags by name (#7484) --- redash/models/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 38f306bb7..3221af2ce 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -564,7 +564,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model): db.session.query(tag_column, usage_count) .group_by(tag_column) .filter(Query.id.in_(queries.options(load_only("id")))) - .order_by(usage_count.desc()) + .order_by(tag_column) ) return query @@ -1137,7 +1137,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model db.session.query(tag_column, usage_count) .group_by(tag_column) .filter(Dashboard.id.in_(dashboards.options(load_only("id")))) - .order_by(usage_count.desc()) + .order_by(tag_column) ) return query