mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
20 Commits
v25.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af71e0ec13 | ||
|
|
594e2f24ef | ||
|
|
3275a9e459 | ||
|
|
3bad8c8e8c | ||
|
|
d0af4499d6 | ||
|
|
4357ea56ae | ||
|
|
5df5ca87a2 | ||
|
|
8387fe6fcb | ||
|
|
e95de2ee4c | ||
|
|
71902e5933 | ||
|
|
53eab14cef | ||
|
|
925bb91d8e | ||
|
|
ec2ca6f986 | ||
|
|
96ea0194e8 | ||
|
|
2776992101 | ||
|
|
85f001982e | ||
|
|
d03a2c4096 | ||
|
|
8c5890482a | ||
|
|
10ce280a96 | ||
|
|
0dd7ac3d2e |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -3,7 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
env:
|
||||
@@ -60,10 +60,10 @@ jobs:
|
||||
mkdir -p /tmp/test-results/unit-tests
|
||||
docker cp tests:/app/coverage.xml ./coverage.xml
|
||||
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
# - name: Upload coverage reports to Codecov
|
||||
# uses: codecov/codecov-action@v3
|
||||
# with:
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Store Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -134,9 +134,9 @@ jobs:
|
||||
COMPOSE_PROJECT_NAME: cypress
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
|
||||
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
|
||||
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
steps:
|
||||
- if: github.event.pull_request.mergeable == 'false'
|
||||
name: Exit if PR is not mergeable
|
||||
|
||||
98
.github/workflows/preview-image.yml
vendored
98
.github/workflows/preview-image.yml
vendored
@@ -39,7 +39,20 @@ jobs:
|
||||
fi
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- amd64
|
||||
- arm64
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-22.04
|
||||
- arch: arm64
|
||||
os: ubuntu-22.04-arm
|
||||
outputs:
|
||||
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
|
||||
needs:
|
||||
- build-skip-check
|
||||
if: needs.build-skip-check.outputs.skip == 'false'
|
||||
@@ -54,11 +67,6 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -69,6 +77,8 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
|
||||
run: |
|
||||
npm install --global --force yarn@1.22.22
|
||||
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
|
||||
@@ -81,40 +91,92 @@ jobs:
|
||||
VERSION_TAG=$(jq -r .version package.json)
|
||||
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# TODO: We can use GitHub Actions's matrix option to reduce the build time.
|
||||
- name: Build and push preview image to Docker Hub
|
||||
id: build-preview
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
redash/redash:preview
|
||||
redash/preview:${{ steps.version.outputs.VERSION_TAG }}
|
||||
${{ vars.DOCKER_USER }}/redash
|
||||
${{ vars.DOCKER_USER }}/preview
|
||||
context: .
|
||||
build-args: |
|
||||
test_all_deps=true
|
||||
cache-from: type=gha,scope=multi-platform
|
||||
cache-to: type=gha,mode=max,scope=multi-platform
|
||||
platforms: linux/amd64,linux/arm64
|
||||
outputs: type=image,push-by-digest=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: true
|
||||
|
||||
- name: Build and push release image to Docker Hub
|
||||
id: build-release
|
||||
uses: docker/build-push-action@v4
|
||||
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||
with:
|
||||
push: true
|
||||
tags: |
|
||||
redash/redash:${{ steps.version.outputs.VERSION_TAG }}
|
||||
${{ vars.DOCKER_USER }}/redash:${{ steps.version.outputs.VERSION_TAG }}
|
||||
context: .
|
||||
build-args: |
|
||||
test_all_deps=true
|
||||
cache-from: type=gha,scope=multi-platform
|
||||
cache-to: type=gha,mode=max,scope=multi-platform
|
||||
platforms: linux/amd64,linux/arm64
|
||||
outputs: type=image,push-by-digest=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: true
|
||||
|
||||
- name: "Failure: output container logs to console"
|
||||
if: failure()
|
||||
run: docker compose logs
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
|
||||
digest="${{ steps.build-preview.outputs.digest}}"
|
||||
else
|
||||
digest="${{ steps.build-release.outputs.digest}}"
|
||||
fi
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
|
||||
merge-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker-image
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASS }}
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create and push manifest for the preview image
|
||||
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/redash:preview \
|
||||
$(printf '${{ vars.DOCKER_USER }}/redash:preview@sha256:%s ' *)
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||
$(printf '${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create and push manifest for the release image
|
||||
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
|
||||
$(printf '${{ vars.DOCKER_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
|
||||
|
||||
2
Makefile
2
Makefile
@@ -34,7 +34,7 @@ clean:
|
||||
|
||||
clean-all: clean
|
||||
docker image rm --force \
|
||||
redash/redash:10.1.0.b50633 redis:7-alpine maildev/maildev:latest \
|
||||
redash/redash:latest redis:7-alpine maildev/maildev:latest \
|
||||
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
|
||||
|
||||
down:
|
||||
|
||||
@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
|
||||
// DataSourcePreviewCard
|
||||
|
||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
|
||||
@@ -96,7 +96,7 @@ function EmptyState({
|
||||
}, []);
|
||||
|
||||
// Show if `onboardingMode=false` or any requested step not completed
|
||||
const shouldShow = !onboardingMode || some(keys(isAvailable), step => isAvailable[step] && !isCompleted[step]);
|
||||
const shouldShow = !onboardingMode || some(keys(isAvailable), (step) => isAvailable[step] && !isCompleted[step]);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
@@ -181,7 +181,7 @@ function EmptyState({
|
||||
];
|
||||
|
||||
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
|
||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
||||
const imageSource = illustrationPath ? illustrationPath : "/static/images/illustrations/" + illustration + ".svg";
|
||||
|
||||
return (
|
||||
<div className="empty-state-wrapper">
|
||||
@@ -196,7 +196,7 @@ function EmptyState({
|
||||
</div>
|
||||
<div className="empty-state__steps">
|
||||
<h4>Let's get started</h4>
|
||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||
<ol>{stepsItems.map((item) => item.node)}</ol>
|
||||
{helpMessage}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
|
||||
export function QuerySourceTypeIcon(props) {
|
||||
return <img src={`static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
|
||||
return <img src={`/static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
|
||||
}
|
||||
|
||||
QuerySourceTypeIcon.propTypes = {
|
||||
|
||||
@@ -18,7 +18,7 @@ function EmptyState({ title, message, refreshButton }) {
|
||||
<div className="query-results-empty-state">
|
||||
<div className="empty-state-content">
|
||||
<div>
|
||||
<img src="static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
|
||||
<img src="/static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<div className="m-b-20">{message}</div>
|
||||
@@ -40,7 +40,7 @@ EmptyState.defaultProps = {
|
||||
|
||||
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
|
||||
const handleDelete = useCallback(
|
||||
e => {
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: "Delete Visualization",
|
||||
@@ -111,7 +111,8 @@ export default function QueryVisualizationTabs({
|
||||
className="add-visualization-button"
|
||||
data-test="NewVisualization"
|
||||
type="link"
|
||||
onClick={() => onAddVisualization()}>
|
||||
onClick={() => onAddVisualization()}
|
||||
>
|
||||
<i className="fa fa-plus" aria-hidden="true" />
|
||||
<span className="m-l-5 hidden-xs">Add Visualization</span>
|
||||
</Button>
|
||||
@@ -119,7 +120,7 @@ export default function QueryVisualizationTabs({
|
||||
}
|
||||
|
||||
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
|
||||
const isFirstVisualization = useCallback(visId => visId === orderedVisualizations[0].id, [orderedVisualizations]);
|
||||
const isFirstVisualization = useCallback((visId) => visId === orderedVisualizations[0].id, [orderedVisualizations]);
|
||||
const isMobile = useMedia({ maxWidth: 768 });
|
||||
|
||||
const [filters, setFilters] = useState([]);
|
||||
@@ -132,9 +133,10 @@ export default function QueryVisualizationTabs({
|
||||
data-test="QueryPageVisualizationTabs"
|
||||
animated={false}
|
||||
tabBarGutter={0}
|
||||
onChange={activeKey => onChangeTab(+activeKey)}
|
||||
destroyInactiveTabPane>
|
||||
{orderedVisualizations.map(visualization => (
|
||||
onChange={(activeKey) => onChangeTab(+activeKey)}
|
||||
destroyInactiveTabPane
|
||||
>
|
||||
{orderedVisualizations.map((visualization) => (
|
||||
<TabPane
|
||||
key={`${visualization.id}`}
|
||||
tab={
|
||||
@@ -144,7 +146,8 @@ export default function QueryVisualizationTabs({
|
||||
visualizationName={visualization.name}
|
||||
onDelete={() => onDeleteVisualization(visualization.id)}
|
||||
/>
|
||||
}>
|
||||
}
|
||||
>
|
||||
{queryResult ? (
|
||||
<VisualizationRenderer
|
||||
visualization={visualization}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { reduce } from "lodash";
|
||||
import localOptions from "@/lib/localOptions";
|
||||
|
||||
function calculateTokensCount(schema) {
|
||||
return reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
|
||||
}
|
||||
|
||||
export default function useAutocompleteFlags(schema) {
|
||||
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]);
|
||||
const isAvailable = true;
|
||||
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
|
||||
|
||||
const toggleAutocomplete = useCallback(state => {
|
||||
const toggleAutocomplete = useCallback((state) => {
|
||||
setIsEnabled(state);
|
||||
localOptions.set("liveAutocomplete", state);
|
||||
}, []);
|
||||
|
||||
@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
|
||||
|
||||
export const SCHEMA_NOT_SUPPORTED = 1;
|
||||
export const SCHEMA_LOAD_ERROR = 2;
|
||||
export const IMG_ROOT = "static/images/db-logos";
|
||||
export const IMG_ROOT = "/static/images/db-logos";
|
||||
|
||||
function mapSchemaColumnsToObject(columns) {
|
||||
return map(columns, column => (isObject(column) ? column : { name: column }));
|
||||
return map(columns, (column) => (isObject(column) ? column : { name: column }));
|
||||
}
|
||||
|
||||
const DataSource = {
|
||||
query: () => axios.get("api/data_sources"),
|
||||
get: ({ id }) => axios.get(`api/data_sources/${id}`),
|
||||
types: () => axios.get("api/data_sources/types"),
|
||||
create: data => axios.post(`api/data_sources`, data),
|
||||
save: data => axios.post(`api/data_sources/${data.id}`, data),
|
||||
test: data => axios.post(`api/data_sources/${data.id}/test`),
|
||||
create: (data) => axios.post(`api/data_sources`, data),
|
||||
save: (data) => axios.post(`api/data_sources/${data.id}`, data),
|
||||
test: (data) => axios.post(`api/data_sources/${data.id}/test`),
|
||||
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
|
||||
fetchSchema: (data, refresh = false) => {
|
||||
const params = {};
|
||||
@@ -27,15 +27,15 @@ const DataSource = {
|
||||
|
||||
return axios
|
||||
.get(`api/data_sources/${data.id}/schema`, { params })
|
||||
.then(data => {
|
||||
.then((data) => {
|
||||
if (has(data, "job")) {
|
||||
return fetchDataFromJob(data.job.id).catch(error =>
|
||||
return fetchDataFromJob(data.job.id).catch((error) =>
|
||||
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
|
||||
);
|
||||
}
|
||||
return has(data, "schema") ? data.schema : Promise.reject();
|
||||
})
|
||||
.then(tables => map(tables, table => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
|
||||
.then((tables) => map(tables, (table) => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ function runCypressCI() {
|
||||
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
|
||||
} = process.env;
|
||||
|
||||
if (GITHUB_REPOSITORY === "getredash/redash") {
|
||||
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
|
||||
process.env.CYPRESS_OPTIONS = "--record";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,36 +3,26 @@
|
||||
* @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);
|
||||
cy.getByTestId("VisualizationPreview").find("g.overplot").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);
|
||||
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("EditVisualizationDialog").contains("button", "Save").click();
|
||||
|
||||
cy.getByTestId("QueryPageVisualizationTabs")
|
||||
.contains("span", chartName)
|
||||
.should("exist");
|
||||
cy.getByTestId("QueryPageVisualizationTabs").contains("span", chartName).should("exist");
|
||||
|
||||
cy.wait("@SaveVisualization").should("have.property", "status", 200);
|
||||
|
||||
return cy.get("@SaveVisualization").then(xhr => {
|
||||
return cy.get("@SaveVisualization").then((xhr) => {
|
||||
const { id, name, options } = xhr.response.body;
|
||||
return cy.wrap({ id, name, options });
|
||||
});
|
||||
@@ -42,19 +32,13 @@ 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").find("table").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.find("table")
|
||||
.should("exist");
|
||||
cy.getByTestId("VisualizationEditor").find("table").should("exist");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
|
||||
cy.getByTestId("VisualizationEditor")
|
||||
.getByTestId("Chart.DataLabels.ShowDataLabels")
|
||||
.should("exist");
|
||||
cy.getByTestId("VisualizationEditor").getByTestId("Chart.DataLabels.ShowDataLabels").should("exist");
|
||||
|
||||
chartSpecificTabbedEditorAssertionFn();
|
||||
|
||||
@@ -63,39 +47,29 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
|
||||
|
||||
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.Type").contains(".ant-select-selection-item", "Auto Detect").should("exist");
|
||||
|
||||
cy.getByTestId("Chart.XAxis.Name")
|
||||
.clear()
|
||||
.type(xaxisLabel);
|
||||
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.Type").contains(".ant-select-selection-item", "Linear").should("exist");
|
||||
|
||||
cy.getByTestId("Chart.LeftYAxis.Name")
|
||||
.clear()
|
||||
.type(yaxisLabel);
|
||||
cy.getByTestId("Chart.LeftYAxis.Name").clear().type(yaxisLabel);
|
||||
|
||||
cy.getByTestId("Chart.LeftYAxis.TickFormat")
|
||||
.clear()
|
||||
.type("+");
|
||||
cy.getByTestId("Chart.LeftYAxis.TickFormat").clear().type("+");
|
||||
|
||||
cy.getByTestId("VisualizationEditor.Tabs.General").click();
|
||||
}
|
||||
|
||||
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
|
||||
cy.createDashboard(title).then(dashboard => {
|
||||
cy.createDashboard(title).then((dashboard) => {
|
||||
const dashboardUrl = `/dashboards/${dashboard.id}`;
|
||||
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`);
|
||||
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 }))
|
||||
.then((chart) => cy.addWidget(dashboard.id, chart.id, { position }))
|
||||
.as(widgetGetters[i]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "redash-client",
|
||||
"version": "25.01.0-dev",
|
||||
"version": "25.03.0-dev",
|
||||
"description": "The frontend part of Redash.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
76
poetry.lock
generated
76
poetry.lock
generated
@@ -2716,42 +2716,42 @@ et-xmlfile = "*"
|
||||
|
||||
[[package]]
|
||||
name = "oracledb"
|
||||
version = "2.1.2"
|
||||
version = "2.5.1"
|
||||
description = "Python interface to Oracle Database"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "oracledb-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ffaba9504c638c29129b484cf547accf750bd0f86df1ca6194646a4d2540691"},
|
||||
{file = "oracledb-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d98deb1e3a500920f5460d457925f0c8cef8d037881fdbd16df1c4734453dd"},
|
||||
{file = "oracledb-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bde2df672704fbe12ab0653f6e808b1ed62de28c6864b17fc3a1fcac9c1fd472"},
|
||||
{file = "oracledb-2.1.2-cp310-cp310-win32.whl", hash = "sha256:3b3798a1220fc8736a37b9280d0ae4cdf263bb203fc6e2b3a82c33f9a2010702"},
|
||||
{file = "oracledb-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:92620efd5eb0d23b252d75f2f2ff1deadf25f44546903e3283760cb276d524ed"},
|
||||
{file = "oracledb-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b913a164e1830d0e955b88d97c5e4da4d2402f8a8b0d38febb6ad5a8ef9e4743"},
|
||||
{file = "oracledb-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c53827344c6d001f492aee0a3acb6c1b6c0f3030c2f5dc8cb86dc4f0bb4dd1ab"},
|
||||
{file = "oracledb-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50225074841d5f9b281d620c012ced4b0946ff5a941c8b639be7babda5190709"},
|
||||
{file = "oracledb-2.1.2-cp311-cp311-win32.whl", hash = "sha256:a043b4df2919411b787bcd24ffa4286249a11d05d29bb20bb076d108c3c6f777"},
|
||||
{file = "oracledb-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:9edce208c26ee018e43b75323888743031be3e9f0c0e4221abf037129c12d949"},
|
||||
{file = "oracledb-2.1.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:08aa313b801dda950918168d3962ba59a617adce143e0c2bf1ee9b847695faaa"},
|
||||
{file = "oracledb-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de5c932b04d3bcdd22c71c0e5c5e1d16b6a3a2fc68dc472ee3a12e677461354c"},
|
||||
{file = "oracledb-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d590caf39b1901bcba394fcda9815438faff0afaf374025f89ef5d65993d0a4"},
|
||||
{file = "oracledb-2.1.2-cp312-cp312-win32.whl", hash = "sha256:1e3ffdfe76c97d1ca13a3fecf239c96d3889015bb5b775dc22b947108044b01e"},
|
||||
{file = "oracledb-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c1eaf8c74bb6de5772de768f2f3f5eb935ab935c633d3a012ddff7e691a2073"},
|
||||
{file = "oracledb-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e2ee06e154e08cc5e4037855d74dc6e37dc054c91a7a1a372bb60d4442e2ed3d"},
|
||||
{file = "oracledb-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a21d84aaf5dddab0cfa8ab7c23272c0295a5c796f212a4ce8a6b499643663dd"},
|
||||
{file = "oracledb-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b337f7cf30753c3a32302fbc25ca80d7ff5049dd9333e681236a674a90c21caf"},
|
||||
{file = "oracledb-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:b5d936763a9b26d32c4e460dbb346c2a962fcc98e6df33dd2d81fdc2eb26f1e4"},
|
||||
{file = "oracledb-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:0ea32b87b7202811d85082f10bf7789747ce45f195be4199c5611e7d76a79e78"},
|
||||
{file = "oracledb-2.1.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:f94b22da87e051e3a8620d2b04d99e1cc9d9abb4da6736d6ae0ca436ba03fb86"},
|
||||
{file = "oracledb-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:391034ee66717dba514e765263d08d18a2aa7badde373f82599b89e46fa3720a"},
|
||||
{file = "oracledb-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a2d9891244b9b94465e30af8cc79380bbb41081c5dc0511cbc94cc250e9e26d"},
|
||||
{file = "oracledb-2.1.2-cp38-cp38-win32.whl", hash = "sha256:9a9a6e0bf61952c2c82614b98fe896d2cda17d81ffca4527556e6607b10e3365"},
|
||||
{file = "oracledb-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:d9a6447589f203ca846526c99a667537b099d54ddeff09d24f9da59bdcc8f98b"},
|
||||
{file = "oracledb-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eb688dd1f8ea2038d17bc84fb651aa1e994b155d3cb8b8387df70ab2a7b4c4c"},
|
||||
{file = "oracledb-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f22c31b894bb085a33d70e174c9bcd0abafc630c2c941ff0d630ee3852f1aa6"},
|
||||
{file = "oracledb-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5bc03520b8bd4dbf2ac4d937d298a85a7208ffbeec738eea92ad7bb00e7134a"},
|
||||
{file = "oracledb-2.1.2-cp39-cp39-win32.whl", hash = "sha256:5d4f6bd1036d7edbb96d8d31f0ca53696a013c00ac82fc19ac0ca374d2265b2c"},
|
||||
{file = "oracledb-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:69bde9770392c1c859b1e1d767dbb9ca4c57e3f2946ca90c779d9402a7e96111"},
|
||||
{file = "oracledb-2.1.2.tar.gz", hash = "sha256:3054bcc295d7378834ba7a5aceb865985e954915f9b07a843ea84c3824c6a0b2"},
|
||||
{file = "oracledb-2.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:54ea7b4da179eb3fefad338685b44fed657a9cd733fb0bfc09d344cfb266355e"},
|
||||
{file = "oracledb-2.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05df7a5a61f4d26c986e235fae6f64a81afaac8f1dbef60e2e9ecf9236218e58"},
|
||||
{file = "oracledb-2.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d17c80063375a5d87a7ab57c8343e5434a16ea74f7be3b56f9100300ef0b69d6"},
|
||||
{file = "oracledb-2.5.1-cp310-cp310-win32.whl", hash = "sha256:51b3911ee822319e20f2e19d816351aac747591a59a0a96cf891c62c2a5c0c0d"},
|
||||
{file = "oracledb-2.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:e4e884625117e50b619c93828affbcffa594029ef8c8b40205394990e6af65a8"},
|
||||
{file = "oracledb-2.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:85318350fa4837b7b637e436fa5f99c17919d6329065e64d1e18e5a7cae52457"},
|
||||
{file = "oracledb-2.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676c221227159d9cee25030c56ff9782f330115cb86164d92d3360f55b07654b"},
|
||||
{file = "oracledb-2.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e78c6de57b4b5df7f932337c57e59b62e34fc4527d2460c0cab10c2ab01825f8"},
|
||||
{file = "oracledb-2.5.1-cp311-cp311-win32.whl", hash = "sha256:0d5974327a1957538a144b073367104cdf8bb39cf056940995b75cb099535589"},
|
||||
{file = "oracledb-2.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:541bb5a107917b9d9eba1346318b42f8b6024e7dd3bef1451f0745364f03399c"},
|
||||
{file = "oracledb-2.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:970a9420cc351d650cc6716122e9aa50cfb8c27f425ffc9d83651fd3edff6090"},
|
||||
{file = "oracledb-2.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6788c128af5a3a45689453fc4832f32b4a0dae2696d9917c7631a2e02865148"},
|
||||
{file = "oracledb-2.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8778daa3f08639232341d802b95ca6da4c0c798c8530e4df331b3286d32e49d5"},
|
||||
{file = "oracledb-2.5.1-cp312-cp312-win32.whl", hash = "sha256:a44613f3dfacb2b9462c3871ee333fa535fbd0ec21942e14019fcfd572487db0"},
|
||||
{file = "oracledb-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:934d02da80bfc030c644c5c43fbe58119dc170f15b4dfdb6fe04c220a1f8730d"},
|
||||
{file = "oracledb-2.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0374481329fa873a2af24eb12de4fd597c6c111e148065200562eb75ea0c6be7"},
|
||||
{file = "oracledb-2.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66e885de106701d1f2a630d19e183e491e4f1ccb8d78855f60396ba15856fb66"},
|
||||
{file = "oracledb-2.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcf446f6250d8edad5367ff03ad73dbbe672a2e4b060c51a774821dd723b0283"},
|
||||
{file = "oracledb-2.5.1-cp313-cp313-win32.whl", hash = "sha256:b02b93199a7073e9b5687fe2dfa83d25ea102ab261c577f9d55820d5ef193dda"},
|
||||
{file = "oracledb-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:173b6d132b230f0617380272181e14fc53aec65aaffe68b557a9b6040716a267"},
|
||||
{file = "oracledb-2.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7d5efc94ce5bb657a5f43e2683e23cc4b4c53c4783e817759869472a113dac26"},
|
||||
{file = "oracledb-2.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6919cb69638a7dda45380d6530b6f2f7fd21ea7bdf8d38936653f9ebc4f7e3d6"},
|
||||
{file = "oracledb-2.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f5eb220945a6e092975ebcb9afc3f1eb10420d04d6bfeace1207ba86d60431"},
|
||||
{file = "oracledb-2.5.1-cp38-cp38-win32.whl", hash = "sha256:aa6ce0dfc64dc7b30bcf477f978538ba82fa7060ecd7a1b9227925b471ae3b50"},
|
||||
{file = "oracledb-2.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:7a3115e4d445e3430d6f34083b7eed607309411f41472b66d145508f7b0c3770"},
|
||||
{file = "oracledb-2.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8a2627a0d29390aaef7211c5b3f7182dfd8e76c969b39d57ee3e43c1057c6fe7"},
|
||||
{file = "oracledb-2.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:730cd03e7fbf05acd32a221ead2a43020b3b91391597eaf728d724548f418b1b"},
|
||||
{file = "oracledb-2.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42524b586733daa896f675acad8b9f2fc2f4380656d60a22a109a573861fc93"},
|
||||
{file = "oracledb-2.5.1-cp39-cp39-win32.whl", hash = "sha256:7958c7796df9f8c97484768c88817dec5c6d49220fc4cccdfde12a1a883f3d46"},
|
||||
{file = "oracledb-2.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:92e0d176e3c76a1916f4e34fc3d84994ad74cce6b8664656c4dbecb8fa7e8c37"},
|
||||
{file = "oracledb-2.5.1.tar.gz", hash = "sha256:63d17ebb95f9129d0ab9386cb632c9e667e3be2c767278cc11a8e4585468de33"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5154,13 +5154,13 @@ six = ">=1.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.25.0"
|
||||
version = "20.26.6"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"},
|
||||
{file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"},
|
||||
{file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"},
|
||||
{file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5169,7 +5169,7 @@ filelock = ">=3.12.2,<4"
|
||||
platformdirs = ">=3.9.1,<5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
|
||||
[[package]]
|
||||
@@ -5493,4 +5493,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.8,<3.11"
|
||||
content-hash = "971596e47325293cbc984bb5a8aabd88a211f4ff4bbd72323f5eb6a168643feb"
|
||||
content-hash = "93b13c8a960e148463fba93cfd826c0f3e7bd822bbda55af7ba708baead293df"
|
||||
|
||||
@@ -12,7 +12,7 @@ force-exclude = '''
|
||||
|
||||
[tool.poetry]
|
||||
name = "redash"
|
||||
version = "25.01.0-dev"
|
||||
version = "25.03.0-dev"
|
||||
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
|
||||
authors = ["Arik Fraimovich <arik@redash.io>"]
|
||||
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
|
||||
@@ -88,6 +88,7 @@ tzlocal = "4.3.1"
|
||||
pyodbc = "5.1.0"
|
||||
debugpy = "^1.8.9"
|
||||
paramiko = "3.4.1"
|
||||
oracledb = "2.5.1"
|
||||
|
||||
[tool.poetry.group.all_ds]
|
||||
optional = true
|
||||
@@ -113,7 +114,6 @@ nzalchemy = "^11.0.2"
|
||||
nzpy = ">=1.15"
|
||||
oauth2client = "4.1.3"
|
||||
openpyxl = "3.0.7"
|
||||
oracledb = "2.1.2"
|
||||
pandas = "1.3.4"
|
||||
phoenixdb = "0.7"
|
||||
pinotdb = ">=0.4.5"
|
||||
|
||||
@@ -14,7 +14,7 @@ from redash.app import create_app # noqa
|
||||
from redash.destinations import import_destinations
|
||||
from redash.query_runner import import_query_runners
|
||||
|
||||
__version__ = "25.01.0-dev"
|
||||
__version__ = "25.03.0-dev"
|
||||
|
||||
|
||||
if os.environ.get("REMOTE_DEBUG"):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
@@ -37,6 +39,129 @@ class Webex(BaseDestination):
|
||||
|
||||
@staticmethod
|
||||
def formatted_attachments_template(subject, description, query_link, alert_link):
|
||||
# Attempt to parse the description to find a 2D array
|
||||
try:
|
||||
# Extract the part of the description that looks like a JSON array
|
||||
start_index = description.find("[")
|
||||
end_index = description.rfind("]") + 1
|
||||
json_array_str = description[start_index:end_index]
|
||||
|
||||
# Decode HTML entities
|
||||
json_array_str = html.unescape(json_array_str)
|
||||
|
||||
# Replace single quotes with double quotes for valid JSON
|
||||
json_array_str = json_array_str.replace("'", '"')
|
||||
|
||||
# Load the JSON array
|
||||
data_array = json.loads(json_array_str)
|
||||
|
||||
# Check if it's a 2D array
|
||||
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
|
||||
# Create a table for the Adaptive Card
|
||||
table_rows = []
|
||||
for row in data_array:
|
||||
table_rows.append(
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
|
||||
for item in row
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Create the body of the card with the table
|
||||
body = (
|
||||
[
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description[:start_index]}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
]
|
||||
+ table_rows
|
||||
+ [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Fallback to the original description if no valid 2D array is found
|
||||
body = [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, fallback to the original description
|
||||
body = [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{subject}",
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"{description}",
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"contentType": "application/vnd.microsoft.card.adaptive",
|
||||
@@ -44,44 +169,7 @@ class Webex(BaseDestination):
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.0",
|
||||
"body": [
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{
|
||||
"type": "Column",
|
||||
"width": 4,
|
||||
"items": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": {subject},
|
||||
"weight": "bolder",
|
||||
"size": "medium",
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": {description},
|
||||
"isSubtle": True,
|
||||
"wrap": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({query_link}) to check your query!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": f"Click [here]({alert_link}) to check your alert!",
|
||||
"wrap": True,
|
||||
"isSubtle": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"body": body,
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -116,6 +204,10 @@ class Webex(BaseDestination):
|
||||
|
||||
# destinations is guaranteed to be a comma-separated string
|
||||
for destination_id in destinations.split(","):
|
||||
destination_id = destination_id.strip() # Remove any leading or trailing whitespace
|
||||
if not destination_id: # Check if the destination_id is empty or blank
|
||||
continue # Skip to the next iteration if it's empty or blank
|
||||
|
||||
payload = deepcopy(template_payload)
|
||||
payload[payload_tag] = destination_id
|
||||
self.post_message(payload, headers)
|
||||
|
||||
@@ -908,6 +908,7 @@ def next_state(op, value, threshold):
|
||||
# boolean value is Python specific and most likely will be confusing to
|
||||
# users.
|
||||
value = str(value).lower()
|
||||
value_is_number = False
|
||||
else:
|
||||
try:
|
||||
value = float(value)
|
||||
|
||||
@@ -304,7 +304,7 @@ class BigQuery(BaseQueryRunner):
|
||||
datasets = self._get_project_datasets(project_id)
|
||||
|
||||
query_base = """
|
||||
SELECT table_schema, table_name, field_path
|
||||
SELECT table_schema, table_name, field_path, data_type
|
||||
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
|
||||
WHERE table_schema NOT IN ('information_schema')
|
||||
"""
|
||||
@@ -325,7 +325,7 @@ class BigQuery(BaseQueryRunner):
|
||||
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
|
||||
if table_name not in schema:
|
||||
schema[table_name] = {"name": table_name, "columns": []}
|
||||
schema[table_name]["columns"].append(row["field_path"])
|
||||
schema[table_name]["columns"].append({"name": row["field_path"], "type": row["data_type"]})
|
||||
|
||||
return list(schema.values())
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class BaseElasticSearch(BaseQueryRunner):
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
self.server_url = self.configuration.get("server", "")
|
||||
if self.server_url[-1] == "/":
|
||||
if self.server_url and self.server_url[-1] == "/":
|
||||
self.server_url = self.server_url[:-1]
|
||||
|
||||
basic_auth_user = self.configuration.get("basic_auth_user", None)
|
||||
|
||||
@@ -152,7 +152,7 @@ class Mysql(BaseSQLQueryRunner):
|
||||
col.table_name as table_name,
|
||||
col.column_name as column_name
|
||||
FROM `information_schema`.`columns` col
|
||||
WHERE col.table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys');
|
||||
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys');
|
||||
"""
|
||||
|
||||
results, error = self.run_query(query, None)
|
||||
|
||||
@@ -6,6 +6,7 @@ import decimal
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
@@ -120,6 +121,17 @@ def json_loads(data, *args, **kwargs):
|
||||
return json.loads(data, *args, **kwargs)
|
||||
|
||||
|
||||
# Convert NaN, Inf, and -Inf to None, as they are not valid JSON values.
|
||||
def _sanitize_data(data):
|
||||
if isinstance(data, dict):
|
||||
return {k: _sanitize_data(v) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [_sanitize_data(v) for v in data]
|
||||
if isinstance(data, float) and (math.isnan(data) or math.isinf(data)):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def json_dumps(data, *args, **kwargs):
|
||||
"""A custom JSON dumping function which passes all parameters to the
|
||||
json.dumps function."""
|
||||
@@ -128,7 +140,7 @@ def json_dumps(data, *args, **kwargs):
|
||||
# Float value nan or inf in Python should be render to None or null in json.
|
||||
# Using allow_nan = True will make Python render nan as NaN, leading to parse error in front-end
|
||||
kwargs.setdefault("allow_nan", False)
|
||||
return json.dumps(data, *args, **kwargs)
|
||||
return json.dumps(_sanitize_data(data), *args, **kwargs)
|
||||
|
||||
|
||||
def mustache_render(template, context=None, **kwargs):
|
||||
|
||||
@@ -33,7 +33,7 @@ from sqlalchemy.orm import mapperlib
|
||||
from sqlalchemy.orm.properties import ColumnProperty
|
||||
from sqlalchemy.orm.query import _ColumnEntity
|
||||
from sqlalchemy.orm.util import AliasedInsp
|
||||
from sqlalchemy.sql.expression import asc, desc
|
||||
from sqlalchemy.sql.expression import asc, desc, nullslast
|
||||
|
||||
|
||||
def get_query_descriptor(query, entity, attr):
|
||||
@@ -225,7 +225,7 @@ class QuerySorter:
|
||||
def assign_order_by(self, entity, attr, func):
|
||||
expr = get_query_descriptor(self.query, entity, attr)
|
||||
if expr is not None:
|
||||
return self.query.order_by(func(expr))
|
||||
return self.query.order_by(nullslast(func(expr)))
|
||||
if not self.silent:
|
||||
raise QuerySorterException("Could not sort query with expression '%s'" % attr)
|
||||
return self.query
|
||||
|
||||
@@ -261,15 +261,19 @@ def test_webex_notify_calls_requests_post():
|
||||
alert.name = "Test Alert"
|
||||
alert.custom_subject = "Test custom subject"
|
||||
alert.custom_body = "Test custom body"
|
||||
|
||||
alert.render_template = mock.Mock(return_value={"Rendered": "template"})
|
||||
|
||||
query = mock.Mock()
|
||||
query.id = 1
|
||||
|
||||
user = mock.Mock()
|
||||
app = mock.Mock()
|
||||
host = "https://localhost:5000"
|
||||
options = {"webex_bot_token": "abcd", "to_room_ids": "1234"}
|
||||
options = {
|
||||
"webex_bot_token": "abcd",
|
||||
"to_room_ids": "1234,5678",
|
||||
"to_person_emails": "example1@test.com,example2@test.com",
|
||||
}
|
||||
metadata = {"Scheduled": False}
|
||||
|
||||
new_state = Alert.TRIGGERED_STATE
|
||||
@@ -277,7 +281,7 @@ def test_webex_notify_calls_requests_post():
|
||||
|
||||
with mock.patch("redash.destinations.webex.requests.post") as mock_post:
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 204
|
||||
mock_response.status_code = 200
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
destination.notify(alert, query, user, new_state, app, host, metadata, options)
|
||||
@@ -285,13 +289,111 @@ def test_webex_notify_calls_requests_post():
|
||||
query_link = f"{host}/queries/{query.id}"
|
||||
alert_link = f"{host}/alerts/{alert.id}"
|
||||
|
||||
formatted_attachments = Webex.formatted_attachments_template(
|
||||
expected_attachments = Webex.formatted_attachments_template(
|
||||
alert.custom_subject, alert.custom_body, query_link, alert_link
|
||||
)
|
||||
|
||||
expected_payload_room = {
|
||||
"markdown": alert.custom_subject + "\n" + alert.custom_body,
|
||||
"attachments": expected_attachments,
|
||||
"roomId": "1234",
|
||||
}
|
||||
|
||||
expected_payload_email = {
|
||||
"markdown": alert.custom_subject + "\n" + alert.custom_body,
|
||||
"attachments": expected_attachments,
|
||||
"toPersonEmail": "example1@test.com",
|
||||
}
|
||||
|
||||
# Check that requests.post was called for both roomId and toPersonEmail destinations
|
||||
mock_post.assert_any_call(
|
||||
destination.api_base_url,
|
||||
json=expected_payload_room,
|
||||
headers={"Authorization": "Bearer abcd"},
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
mock_post.assert_any_call(
|
||||
destination.api_base_url,
|
||||
json=expected_payload_email,
|
||||
headers={"Authorization": "Bearer abcd"},
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert mock_response.status_code == 200
|
||||
|
||||
|
||||
def test_webex_notify_handles_blank_entries():
|
||||
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
|
||||
alert.id = 1
|
||||
alert.name = "Test Alert"
|
||||
alert.custom_subject = "Test custom subject"
|
||||
alert.custom_body = "Test custom body"
|
||||
alert.render_template = mock.Mock(return_value={"Rendered": "template"})
|
||||
|
||||
query = mock.Mock()
|
||||
query.id = 1
|
||||
|
||||
user = mock.Mock()
|
||||
app = mock.Mock()
|
||||
host = "https://localhost:5000"
|
||||
options = {
|
||||
"webex_bot_token": "abcd",
|
||||
"to_room_ids": "",
|
||||
"to_person_emails": "",
|
||||
}
|
||||
metadata = {"Scheduled": False}
|
||||
|
||||
new_state = Alert.TRIGGERED_STATE
|
||||
destination = Webex(options)
|
||||
|
||||
with mock.patch("redash.destinations.webex.requests.post") as mock_post:
|
||||
destination.notify(alert, query, user, new_state, app, host, metadata, options)
|
||||
|
||||
# Ensure no API calls are made when destinations are blank
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
def test_webex_notify_handles_2d_array():
|
||||
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
|
||||
alert.id = 1
|
||||
alert.name = "Test Alert"
|
||||
alert.custom_subject = "Test custom subject"
|
||||
alert.custom_body = "Test custom body with table [['Col1', 'Col2'], ['Val1', 'Val2']]"
|
||||
alert.render_template = mock.Mock(return_value={"Rendered": "template"})
|
||||
|
||||
query = mock.Mock()
|
||||
query.id = 1
|
||||
|
||||
user = mock.Mock()
|
||||
app = mock.Mock()
|
||||
host = "https://localhost:5000"
|
||||
options = {
|
||||
"webex_bot_token": "abcd",
|
||||
"to_room_ids": "1234",
|
||||
}
|
||||
metadata = {"Scheduled": False}
|
||||
|
||||
new_state = Alert.TRIGGERED_STATE
|
||||
destination = Webex(options)
|
||||
|
||||
with mock.patch("redash.destinations.webex.requests.post") as mock_post:
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
destination.notify(alert, query, user, new_state, app, host, metadata, options)
|
||||
|
||||
query_link = f"{host}/queries/{query.id}"
|
||||
alert_link = f"{host}/alerts/{alert.id}"
|
||||
|
||||
expected_attachments = Webex.formatted_attachments_template(
|
||||
alert.custom_subject, alert.custom_body, query_link, alert_link
|
||||
)
|
||||
|
||||
expected_payload = {
|
||||
"markdown": alert.custom_subject + "\n" + alert.custom_body,
|
||||
"attachments": formatted_attachments,
|
||||
"attachments": expected_attachments,
|
||||
"roomId": "1234",
|
||||
}
|
||||
|
||||
@@ -302,7 +404,60 @@ def test_webex_notify_calls_requests_post():
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert mock_response.status_code == 204
|
||||
assert mock_response.status_code == 200
|
||||
|
||||
|
||||
def test_webex_notify_handles_1d_array():
|
||||
alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"])
|
||||
alert.id = 1
|
||||
alert.name = "Test Alert"
|
||||
alert.custom_subject = "Test custom subject"
|
||||
alert.custom_body = "Test custom body with 1D array, however unlikely ['Col1', 'Col2']"
|
||||
alert.render_template = mock.Mock(return_value={"Rendered": "template"})
|
||||
|
||||
query = mock.Mock()
|
||||
query.id = 1
|
||||
|
||||
user = mock.Mock()
|
||||
app = mock.Mock()
|
||||
host = "https://localhost:5000"
|
||||
options = {
|
||||
"webex_bot_token": "abcd",
|
||||
"to_room_ids": "1234",
|
||||
}
|
||||
metadata = {"Scheduled": False}
|
||||
|
||||
new_state = Alert.TRIGGERED_STATE
|
||||
destination = Webex(options)
|
||||
|
||||
with mock.patch("redash.destinations.webex.requests.post") as mock_post:
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
destination.notify(alert, query, user, new_state, app, host, metadata, options)
|
||||
|
||||
query_link = f"{host}/queries/{query.id}"
|
||||
alert_link = f"{host}/alerts/{alert.id}"
|
||||
|
||||
expected_attachments = Webex.formatted_attachments_template(
|
||||
alert.custom_subject, alert.custom_body, query_link, alert_link
|
||||
)
|
||||
|
||||
expected_payload = {
|
||||
"markdown": alert.custom_subject + "\n" + alert.custom_body,
|
||||
"attachments": expected_attachments,
|
||||
"roomId": "1234",
|
||||
}
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
destination.api_base_url,
|
||||
json=expected_payload,
|
||||
headers={"Authorization": "Bearer abcd"},
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert mock_response.status_code == 200
|
||||
|
||||
|
||||
def test_datadog_notify_calls_requests_post():
|
||||
|
||||
31
tests/utils/test_json_dumps.py
Normal file
31
tests/utils/test_json_dumps.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from redash.utils import json_dumps, json_loads
|
||||
from tests import BaseTestCase
|
||||
|
||||
|
||||
class TestJsonDumps(BaseTestCase):
|
||||
"""
|
||||
NaN, Inf, and -Inf are sanitized to None.
|
||||
"""
|
||||
|
||||
def test_data_with_nan_is_sanitized(self):
|
||||
input_data = {
|
||||
"columns": [
|
||||
{"name": "_col0", "friendly_name": "_col0", "type": "float"},
|
||||
{"name": "_col1", "friendly_name": "_col1", "type": "float"},
|
||||
{"name": "_col2", "friendly_name": "_col1", "type": "float"},
|
||||
{"name": "_col3", "friendly_name": "_col1", "type": "float"},
|
||||
],
|
||||
"rows": [{"_col0": 1.0, "_col1": float("nan"), "_col2": float("inf"), "_col3": float("-inf")}],
|
||||
}
|
||||
expected_output_data = {
|
||||
"columns": [
|
||||
{"name": "_col0", "friendly_name": "_col0", "type": "float"},
|
||||
{"name": "_col1", "friendly_name": "_col1", "type": "float"},
|
||||
{"name": "_col2", "friendly_name": "_col1", "type": "float"},
|
||||
{"name": "_col3", "friendly_name": "_col1", "type": "float"},
|
||||
],
|
||||
"rows": [{"_col0": 1.0, "_col1": None, "_col2": None, "_col3": None}],
|
||||
}
|
||||
json_data = json_dumps(input_data)
|
||||
actual_output_data = json_loads(json_data)
|
||||
self.assertEquals(actual_output_data, expected_output_data)
|
||||
@@ -46,7 +46,7 @@
|
||||
"@types/jest": "^26.0.18",
|
||||
"@types/leaflet": "^1.5.19",
|
||||
"@types/numeral": "0.0.28",
|
||||
"@types/plotly.js": "^1.54.22",
|
||||
"@types/plotly.js": "^2.35.2",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/tinycolor2": "^1.4.2",
|
||||
@@ -91,7 +91,7 @@
|
||||
"leaflet.markercluster": "^1.1.0",
|
||||
"lodash": "^4.17.10",
|
||||
"numeral": "^2.0.6",
|
||||
"plotly.js": "1.58.5",
|
||||
"plotly.js": "2.35.3",
|
||||
"react-pivottable": "^0.9.0",
|
||||
"react-sortable-hoc": "^1.10.1",
|
||||
"tinycolor2": "^1.4.1",
|
||||
|
||||
@@ -27,15 +27,17 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
@@ -42,12 +44,13 @@
|
||||
"yaxis2": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null,
|
||||
"overlaying": "y",
|
||||
"side": "right"
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
|
||||
@@ -25,18 +25,21 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
},
|
||||
"hovermode": "x"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
@@ -40,15 +42,17 @@
|
||||
"yaxis2": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null,
|
||||
"overlaying": "y",
|
||||
"side": "right"
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
},
|
||||
"hovermode": "x"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,21 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
},
|
||||
"hovermode": "x"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,21 @@
|
||||
"automargin": true,
|
||||
"showticklabels": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "-"
|
||||
},
|
||||
"yaxis": {
|
||||
"automargin": true,
|
||||
"title": null,
|
||||
"tickformat": null,
|
||||
"type": "linear",
|
||||
"autorange": true,
|
||||
"range": null
|
||||
},
|
||||
},
|
||||
"hoverlabel": {
|
||||
"namelength": -1
|
||||
}
|
||||
},
|
||||
"hovermode": "x"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { prepareCustomChartData, createCustomChartRenderer } from "./customChart
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'setPlotConfig' does not exist on type 't... Remove this comment to see the full error message
|
||||
Plotly.setPlotConfig({
|
||||
modeBarButtonsToRemove: ["sendDataToCloud"],
|
||||
modeBarButtonsToAdd: ["togglespikelines", "v1hovermode"],
|
||||
});
|
||||
|
||||
export {
|
||||
|
||||
@@ -99,8 +99,8 @@ function prepareSeries(series: any, options: any, numSeries: any, additionalOpti
|
||||
};
|
||||
|
||||
const sourceData = new Map();
|
||||
|
||||
const labelsValuesMap = new Map();
|
||||
const xValues: any[] = [];
|
||||
const yValues: any[] = [];
|
||||
|
||||
const yErrorValues: any = [];
|
||||
each(data, row => {
|
||||
@@ -108,27 +108,20 @@ function prepareSeries(series: any, options: any, numSeries: any, additionalOpti
|
||||
const y = cleanYValue(row.y, seriesYAxis === "y2" ? options.yAxis[1].type : options.yAxis[0].type); // depends on series type!
|
||||
const yError = cleanNumber(row.yError); // always number
|
||||
const size = cleanNumber(row.size); // always number
|
||||
if (labelsValuesMap.has(x)) {
|
||||
labelsValuesMap.set(x, labelsValuesMap.get(x) + y);
|
||||
} else {
|
||||
labelsValuesMap.set(x, y);
|
||||
}
|
||||
const aggregatedY = labelsValuesMap.get(x);
|
||||
|
||||
sourceData.set(x, {
|
||||
x,
|
||||
y: aggregatedY,
|
||||
y,
|
||||
yError,
|
||||
size,
|
||||
yPercent: null, // will be updated later
|
||||
row,
|
||||
});
|
||||
xValues.push(x);
|
||||
yValues.push(y);
|
||||
yErrorValues.push(yError);
|
||||
});
|
||||
|
||||
const xValues = Array.from(labelsValuesMap.keys());
|
||||
const yValues = Array.from(labelsValuesMap.values());
|
||||
|
||||
const plotlySeries = {
|
||||
visible: true,
|
||||
hoverinfo: hoverInfoPattern,
|
||||
|
||||
@@ -21,7 +21,7 @@ function prepareXAxis(axisOptions: any, additionalOptions: any) {
|
||||
title: getAxisTitle(axisOptions),
|
||||
type: getAxisScaleType(axisOptions),
|
||||
automargin: true,
|
||||
tickformat: axisOptions.tickFormat,
|
||||
tickformat: axisOptions.tickFormat ?? null,
|
||||
};
|
||||
|
||||
if (additionalOptions.sortX && axis.type === "category") {
|
||||
@@ -49,7 +49,7 @@ function prepareYAxis(axisOptions: any) {
|
||||
automargin: true,
|
||||
autorange: true,
|
||||
range: null,
|
||||
tickformat: axisOptions.tickFormat,
|
||||
tickformat: axisOptions.tickFormat ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ function prepareBoxLayout(layout: any, options: any, data: any) {
|
||||
}
|
||||
|
||||
export default function prepareLayout(element: any, options: any, data: any) {
|
||||
const layout = {
|
||||
const layout: any = {
|
||||
margin: { l: 10, r: 10, b: 5, t: 20, pad: 4 },
|
||||
// plot size should be at least 5x5px
|
||||
width: Math.max(5, Math.floor(element.offsetWidth)),
|
||||
@@ -124,6 +124,10 @@ export default function prepareLayout(element: any, options: any, data: any) {
|
||||
},
|
||||
};
|
||||
|
||||
if (["line", "area", "column"].includes(options.globalSeriesType)) {
|
||||
layout.hovermode = options.swappedAxes ? 'y' : 'x';
|
||||
}
|
||||
|
||||
switch (options.globalSeriesType) {
|
||||
case "pie":
|
||||
return preparePieLayout(layout, options, data);
|
||||
|
||||
@@ -5,7 +5,7 @@ Object {
|
||||
"columns": Array [
|
||||
Object {
|
||||
"alignContent": "right",
|
||||
"allowHTML": true,
|
||||
"allowHTML": false,
|
||||
"allowSearch": false,
|
||||
"booleanValues": Array [
|
||||
"false",
|
||||
@@ -38,7 +38,7 @@ Object {
|
||||
"columns": Array [
|
||||
Object {
|
||||
"alignContent": "left",
|
||||
"allowHTML": true,
|
||||
"allowHTML": false,
|
||||
"allowSearch": false,
|
||||
"booleanValues": Array [
|
||||
"false",
|
||||
@@ -71,7 +71,7 @@ Object {
|
||||
"columns": Array [
|
||||
Object {
|
||||
"alignContent": "left",
|
||||
"allowHTML": true,
|
||||
"allowHTML": false,
|
||||
"allowSearch": false,
|
||||
"booleanValues": Array [
|
||||
"false",
|
||||
@@ -104,7 +104,7 @@ Object {
|
||||
"columns": Array [
|
||||
Object {
|
||||
"alignContent": "left",
|
||||
"allowHTML": true,
|
||||
"allowHTML": false,
|
||||
"allowSearch": true,
|
||||
"booleanValues": Array [
|
||||
"false",
|
||||
@@ -137,7 +137,7 @@ Object {
|
||||
"columns": Array [
|
||||
Object {
|
||||
"alignContent": "left",
|
||||
"allowHTML": true,
|
||||
"allowHTML": false,
|
||||
"allowSearch": false,
|
||||
"booleanValues": Array [
|
||||
"false",
|
||||
|
||||
@@ -54,7 +54,7 @@ function getDefaultColumnsOptions(columns: any) {
|
||||
allowSearch: false,
|
||||
alignContent: getColumnContentAlignment(col.type),
|
||||
// `string` cell options
|
||||
allowHTML: true,
|
||||
allowHTML: false,
|
||||
highlightLinks: false,
|
||||
}));
|
||||
}
|
||||
|
||||
1988
viz-lib/yarn.lock
1988
viz-lib/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user