Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
af71e0ec13 Bump serialize-javascript from 6.0.1 to 6.0.2 in /viz-lib
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-05 14:32:00 +00:00
154 changed files with 3678 additions and 6351 deletions

View File

@@ -18,7 +18,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:18-alpine image: pgautoupgrade/pgautoupgrade:latest
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -66,7 +66,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:18-alpine image: pgautoupgrade/pgautoupgrade:latest
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -2,7 +2,7 @@ name: Periodic Snapshot
on: on:
schedule: schedule:
- cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month - cron: '10 0 1 * *' # 10 minutes after midnight on the first of every month
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: bump:
@@ -24,7 +24,6 @@ permissions:
jobs: jobs:
bump-version-and-tag: bump-version-and-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref_name == github.event.repository.default_branch
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:

View File

@@ -32,9 +32,6 @@ jobs:
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push' echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT" echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
echo 'Docker repository is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else else
echo 'Docker user and password are set and branch is `master`.' echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.' echo 'Building + pushing `preview` image.'
@@ -100,8 +97,8 @@ jobs:
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }} if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with: with:
tags: | tags: |
${{ vars.DOCKER_REPOSITORY }}/redash ${{ vars.DOCKER_USER }}/redash
${{ vars.DOCKER_REPOSITORY }}/preview ${{ vars.DOCKER_USER }}/preview
context: . context: .
build-args: | build-args: |
test_all_deps=true test_all_deps=true
@@ -117,11 +114,11 @@ jobs:
if: ${{ github.event.inputs.dockerRepository == 'redash' }} if: ${{ github.event.inputs.dockerRepository == 'redash' }}
with: with:
tags: | tags: |
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }} ${{ vars.DOCKER_USER }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: . context: .
build-args: | build-args: |
test_all_deps=true test_all_deps=true
outputs: type=image,push-by-digest=false,push=true outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }} cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env: env:
@@ -172,14 +169,14 @@ jobs:
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }} if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
working-directory: ${{ runner.temp }}/digests working-directory: ${{ runner.temp }}/digests
run: | run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \ docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/redash:preview \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *) $(printf '${{ vars.DOCKER_USER }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \ docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *) $(printf '${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
- name: Create and push manifest for the release image - name: Create and push manifest for the release image
if: ${{ github.event.inputs.dockerRepository == 'redash' }} if: ${{ github.event.inputs.dockerRepository == 'redash' }}
working-directory: ${{ runner.temp }}/digests working-directory: ${{ runner.temp }}/digests
run: | run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \ docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *) $(printf '${{ vars.DOCKER_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)

View File

@@ -95,7 +95,7 @@ EOF
WORKDIR /app WORKDIR /app
ENV POETRY_VERSION=2.1.4 ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/etc/poetry ENV POETRY_HOME=/etc/poetry
ENV POETRY_VIRTUALENVS_CREATE=false ENV POETRY_VIRTUALENVS_CREATE=false
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -

View File

@@ -1,4 +1,4 @@
.PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash .PHONY: compose_build up test_db create_database clean clean-all down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
compose_build: .env compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
@@ -32,6 +32,11 @@ clean:
docker image prune --force docker image prune --force
docker volume prune --force docker volume prune --force
clean-all: clean
docker image rm --force \
redash/redash:latest redis:7-alpine maildev/maildev:latest \
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
down: down:
docker compose down docker compose down

View File

@@ -46,7 +46,7 @@ server() {
MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS=${MAX_REQUESTS:-1000}
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60} TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT --limit-request-line ${REDASH_GUNICORN_LIMIT_REQUEST_LINE:-0} exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT
} }
create_db() { create_db() {

View File

@@ -15,7 +15,7 @@ body {
display: table; display: table;
width: 100%; width: 100%;
padding: 10px; padding: 10px;
height: calc(100% - 116px); height: calc(100vh - 116px);
} }
@media (min-width: 992px) { @media (min-width: 992px) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -20,7 +20,7 @@ html {
html, html,
body { body {
height: 100%; min-height: 100vh;
} }
body { body {
@@ -35,7 +35,7 @@ body {
} }
#application-root { #application-root {
height: 100%; min-height: 100vh;
} }
#application-root, #application-root,

View File

@@ -10,7 +10,7 @@
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
width: 1px; width: 1px;
height: 100%; height: 100vh;
} }
} }

View File

@@ -8,7 +8,7 @@ body.fixed-layout {
padding-bottom: 0; padding-bottom: 0;
width: 100vw; width: 100vw;
height: 100%; height: 100vh;
.application-layout-content > div { .application-layout-content > div {
display: flex; display: flex;
@@ -90,7 +90,7 @@ body.fixed-layout {
.embed__vis { .embed__vis {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
height: calc(~'100% - 25px'); height: calc(~'100vh - 25px');
> .embed-heading { > .embed-heading {
flex: 0 0 auto; flex: 0 0 auto;

View File

@@ -7,10 +7,10 @@ body #application-root {
flex-direction: row; flex-direction: row;
justify-content: stretch; justify-content: stretch;
padding-bottom: 0 !important; padding-bottom: 0 !important;
height: 100%; height: 100vh;
.application-layout-side-menu { .application-layout-side-menu {
height: 100%; height: 100vh;
position: relative; position: relative;
@media @mobileBreakpoint { @media @mobileBreakpoint {
@@ -47,10 +47,6 @@ body #application-root {
} }
} }
body > section {
height: 100%;
}
body.fixed-layout #application-root { body.fixed-layout #application-root {
.application-layout-content { .application-layout-content {
padding-bottom: 0; padding-bottom: 0;

View File

@@ -51,7 +51,7 @@
right: 0; right: 0;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100% + 15px) / 12) 5px; background-size: calc((100% + 15px) / 6) 5px;
background-position: -7px 1px; background-position: -7px 1px;
} }
} }

View File

@@ -9,85 +9,121 @@ const DYNAMIC_DATE_OPTIONS = [
name: "This week", name: "This week",
value: getDynamicDateRangeFromString("d_this_week"), value: getDynamicDateRangeFromString("d_this_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_this_week").value()[0].format("MMM D") + getDynamicDateRangeFromString("d_this_week")
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_this_week").value()[1].format("MMM D"), getDynamicDateRangeFromString("d_this_week")
.value()[1]
.format("MMM D"),
}, },
{ {
name: "This month", name: "This month",
value: getDynamicDateRangeFromString("d_this_month"), value: getDynamicDateRangeFromString("d_this_month"),
label: () => getDynamicDateRangeFromString("d_this_month").value()[0].format("MMMM"), label: () =>
getDynamicDateRangeFromString("d_this_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "This year", name: "This year",
value: getDynamicDateRangeFromString("d_this_year"), value: getDynamicDateRangeFromString("d_this_year"),
label: () => getDynamicDateRangeFromString("d_this_year").value()[0].format("YYYY"), label: () =>
getDynamicDateRangeFromString("d_this_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last week", name: "Last week",
value: getDynamicDateRangeFromString("d_last_week"), value: getDynamicDateRangeFromString("d_last_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_last_week").value()[0].format("MMM D") + getDynamicDateRangeFromString("d_last_week")
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_last_week").value()[1].format("MMM D"), getDynamicDateRangeFromString("d_last_week")
.value()[1]
.format("MMM D"),
}, },
{ {
name: "Last month", name: "Last month",
value: getDynamicDateRangeFromString("d_last_month"), value: getDynamicDateRangeFromString("d_last_month"),
label: () => getDynamicDateRangeFromString("d_last_month").value()[0].format("MMMM"), label: () =>
getDynamicDateRangeFromString("d_last_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "Last year", name: "Last year",
value: getDynamicDateRangeFromString("d_last_year"), value: getDynamicDateRangeFromString("d_last_year"),
label: () => getDynamicDateRangeFromString("d_last_year").value()[0].format("YYYY"), label: () =>
getDynamicDateRangeFromString("d_last_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last 7 days", name: "Last 7 days",
value: getDynamicDateRangeFromString("d_last_7_days"), value: getDynamicDateRangeFromString("d_last_7_days"),
label: () => getDynamicDateRangeFromString("d_last_7_days").value()[0].format("MMM D") + " - Today", label: () =>
getDynamicDateRangeFromString("d_last_7_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 14 days", name: "Last 14 days",
value: getDynamicDateRangeFromString("d_last_14_days"), value: getDynamicDateRangeFromString("d_last_14_days"),
label: () => getDynamicDateRangeFromString("d_last_14_days").value()[0].format("MMM D") + " - Today", label: () =>
getDynamicDateRangeFromString("d_last_14_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 30 days", name: "Last 30 days",
value: getDynamicDateRangeFromString("d_last_30_days"), value: getDynamicDateRangeFromString("d_last_30_days"),
label: () => getDynamicDateRangeFromString("d_last_30_days").value()[0].format("MMM D") + " - Today", label: () =>
getDynamicDateRangeFromString("d_last_30_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 60 days", name: "Last 60 days",
value: getDynamicDateRangeFromString("d_last_60_days"), value: getDynamicDateRangeFromString("d_last_60_days"),
label: () => getDynamicDateRangeFromString("d_last_60_days").value()[0].format("MMM D") + " - Today", label: () =>
getDynamicDateRangeFromString("d_last_60_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 90 days", name: "Last 90 days",
value: getDynamicDateRangeFromString("d_last_90_days"), value: getDynamicDateRangeFromString("d_last_90_days"),
label: () => getDynamicDateRangeFromString("d_last_90_days").value()[0].format("MMM D") + " - Today", label: () =>
getDynamicDateRangeFromString("d_last_90_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 12 months", name: "Last 12 months",
value: getDynamicDateRangeFromString("d_last_12_months"), value: getDynamicDateRangeFromString("d_last_12_months"),
label: null, label: null,
}, },
{
name: "Last 10 years",
value: getDynamicDateRangeFromString("d_last_10_years"),
label: null,
},
]; ];
const DYNAMIC_DATETIME_OPTIONS = [ const DYNAMIC_DATETIME_OPTIONS = [
{ {
name: "Today", name: "Today",
value: getDynamicDateRangeFromString("d_today"), value: getDynamicDateRangeFromString("d_today"),
label: () => getDynamicDateRangeFromString("d_today").value()[0].format("MMM D"), label: () =>
getDynamicDateRangeFromString("d_today")
.value()[0]
.format("MMM D"),
}, },
{ {
name: "Yesterday", name: "Yesterday",
value: getDynamicDateRangeFromString("d_yesterday"), value: getDynamicDateRangeFromString("d_yesterday"),
label: () => getDynamicDateRangeFromString("d_yesterday").value()[0].format("MMM D"), label: () =>
getDynamicDateRangeFromString("d_yesterday")
.value()[0]
.format("MMM D"),
}, },
...DYNAMIC_DATE_OPTIONS, ...DYNAMIC_DATE_OPTIONS,
]; ];

View File

@@ -10,10 +10,6 @@ export interface PaginationOptions {
itemsPerPage?: number; itemsPerPage?: number;
} }
export interface SearchOptions {
isServerSideFTS?: boolean;
}
export interface Controller<I, P = any> { export interface Controller<I, P = any> {
params: P; // TODO: Find out what params is (except merging with props) params: P; // TODO: Find out what params is (except merging with props)
@@ -22,7 +18,7 @@ export interface Controller<I, P = any> {
// search // search
searchTerm?: string; searchTerm?: string;
updateSearch: (searchTerm: string, searchOptions?: SearchOptions) => void; updateSearch: (searchTerm: string) => void;
// tags // tags
selectedTags: string[]; selectedTags: string[];
@@ -32,7 +28,6 @@ export interface Controller<I, P = any> {
orderByField?: string; orderByField?: string;
orderByReverse: boolean; orderByReverse: boolean;
toggleSorting: (orderByField: string) => void; toggleSorting: (orderByField: string) => void;
setSorting: (orderByField: string, orderByReverse: boolean) => void;
// pagination // pagination
page: number; page: number;
@@ -98,7 +93,7 @@ export interface ItemsListWrappedComponentProps<I, P = any> {
export function wrap<I, P = any>( export function wrap<I, P = any>(
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>, WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
createItemsSource: () => ItemsSource, createItemsSource: () => ItemsSource,
createStateStorage: ( { ...props }) => StateStorage createStateStorage: () => StateStorage
) { ) {
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> { class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
private _itemsSource: ItemsSource; private _itemsSource: ItemsSource;
@@ -121,7 +116,7 @@ export function wrap<I, P = any>(
constructor(props: ItemsListWrapperProps) { constructor(props: ItemsListWrapperProps) {
super(props); super(props);
const stateStorage = createStateStorage({ ...props }); const stateStorage = createStateStorage();
const itemsSource = createItemsSource(); const itemsSource = createItemsSource();
this._itemsSource = itemsSource; this._itemsSource = itemsSource;
@@ -144,33 +139,11 @@ export function wrap<I, P = any>(
this.props.onError!(error); this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false }); const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource; const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
let isRunningUpdateSearch = false;
let pendingUpdateSearchParams: any[] | null = null;
const debouncedUpdateSearch = debounce(async (...params) => {
// Avoid running multiple updateSerch concurrently.
// If an updateSearch is already running, we save the params for the latest call.
// When the current updateSearch is finished, we call debouncedUpdateSearch again with the saved params.
if (isRunningUpdateSearch) {
pendingUpdateSearchParams = params;
return;
}
isRunningUpdateSearch = true;
await updateSearch(...params);
isRunningUpdateSearch = false;
if (pendingUpdateSearchParams) {
const pendingParams = pendingUpdateSearchParams;
pendingUpdateSearchParams = null;
debouncedUpdateSearch(...pendingParams);
}
}, 200);
this.state = { this.state = {
...initialState, ...initialState,
toggleSorting, // eslint-disable-line react/no-unused-state toggleSorting, // eslint-disable-line react/no-unused-state
setSorting, // eslint-disable-line react/no-unused-state updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state
updateSearch: debouncedUpdateSearch, // eslint-disable-line react/no-unused-state
updateSelectedTags, // eslint-disable-line react/no-unused-state updateSelectedTags, // eslint-disable-line react/no-unused-state
updatePagination, // eslint-disable-line react/no-unused-state updatePagination, // eslint-disable-line react/no-unused-state
update, // eslint-disable-line react/no-unused-state update, // eslint-disable-line react/no-unused-state

View File

@@ -39,12 +39,14 @@ export class ItemsSource {
const customParams = {}; const customParams = {};
const context = { const context = {
...this.getCallbackContext(), ...this.getCallbackContext(),
setCustomParams: (params) => { setCustomParams: params => {
extend(customParams, params); extend(customParams, params);
}, },
}; };
return this._beforeUpdate().then(() => { return this._beforeUpdate().then(() => {
const fetchToken = Math.random().toString(36).substr(2); const fetchToken = Math.random()
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken; this._currentFetchToken = fetchToken;
return this._fetcher return this._fetcher
.fetch(changes, state, context) .fetch(changes, state, context)
@@ -57,7 +59,7 @@ export class ItemsSource {
return this._afterUpdate(); return this._afterUpdate();
} }
}) })
.catch((error) => this.handleError(error)); .catch(error => this.handleError(error));
}); });
} }
@@ -122,35 +124,28 @@ export class ItemsSource {
}); });
}; };
toggleSorting = (orderByField) => { toggleSorting = orderByField => {
this._sorter.toggleField(orderByField); this._sorter.toggleField(orderByField);
this._savedOrderByField = this._sorter.field; this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true }); this._changed({ sorting: true });
}; };
setSorting = (orderByField, orderByReverse) => { updateSearch = searchTerm => {
this._sorter.setField(orderByField);
this._sorter.setReverse(orderByReverse);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
updateSearch = (searchTerm, options) => {
// here we update state directly, but later `fetchData` will update it properly // here we update state directly, but later `fetchData` will update it properly
this._searchTerm = searchTerm; this._searchTerm = searchTerm;
// in search mode ignore the ordering and use the ranking order // in search mode ignore the ordering and use the ranking order
// provided by the server-side FTS backend instead, unless it was // provided by the server-side FTS backend instead, unless it was
// requested by the user by actively ordering in search mode // requested by the user by actively ordering in search mode
if (searchTerm === "" || !options?.isServerSideFTS) { if (searchTerm === "") {
this._sorter.setField(this._savedOrderByField); // restore ordering this._sorter.setField(this._savedOrderByField); // restore ordering
} else { } else {
this._sorter.setField(null); this._sorter.setField(null);
} }
this._paginator.setPage(1); this._paginator.setPage(1);
return this._changed({ search: true, pagination: { page: true } }); this._changed({ search: true, pagination: { page: true } });
}; };
updateSelectedTags = (selectedTags) => { updateSelectedTags = selectedTags => {
this._selectedTags = selectedTags; this._selectedTags = selectedTags;
this._paginator.setPage(1); this._paginator.setPage(1);
this._changed({ tags: true, pagination: { page: true } }); this._changed({ tags: true, pagination: { page: true } });
@@ -158,7 +153,7 @@ export class ItemsSource {
update = () => this._changed(); update = () => this._changed();
handleError = (error) => { handleError = error => {
if (isFunction(this.onError)) { if (isFunction(this.onError)) {
this.onError(error); this.onError(error);
} }
@@ -177,7 +172,7 @@ export class ResourceItemsSource extends ItemsSource {
processResults: (results, context) => { processResults: (results, context) => {
let processItem = getItemProcessor(context); let processItem = getItemProcessor(context);
processItem = isFunction(processItem) ? processItem : identity; processItem = isFunction(processItem) ? processItem : identity;
return map(results, (item) => processItem(item, context)); return map(results, item => processItem(item, context));
}, },
}); });
} }

View File

@@ -44,7 +44,7 @@ export const Columns = {
date(overrides) { date(overrides) {
return extend( return extend(
{ {
render: (text) => formatDate(text), render: text => formatDate(text),
}, },
overrides overrides
); );
@@ -52,7 +52,7 @@ export const Columns = {
dateTime(overrides) { dateTime(overrides) {
return extend( return extend(
{ {
render: (text) => formatDateTime(text), render: text => formatDateTime(text),
}, },
overrides overrides
); );
@@ -62,7 +62,7 @@ export const Columns = {
{ {
width: "1%", width: "1%",
className: "text-nowrap", className: "text-nowrap",
render: (text) => durationHumanize(text), render: text => durationHumanize(text),
}, },
overrides overrides
); );
@@ -70,7 +70,7 @@ export const Columns = {
timeAgo(overrides, timeAgoCustomProps = undefined) { timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend( return extend(
{ {
render: (value) => <TimeAgo date={value} {...timeAgoCustomProps} />, render: value => <TimeAgo date={value} {...timeAgoCustomProps} />,
}, },
overrides overrides
); );
@@ -110,7 +110,6 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
setSorting: PropTypes.func,
"data-test": PropTypes.string, "data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
@@ -128,15 +127,18 @@ export default class ItemsTable extends React.Component {
}; };
prepareColumns() { prepareColumns() {
const { orderByField, orderByReverse } = this.props; const { orderByField, orderByReverse, toggleSorting } = this.props;
const orderByDirection = orderByReverse ? "descend" : "ascend"; const orderByDirection = orderByReverse ? "descend" : "ascend";
return map( return map(
map( map(
filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)), filter(this.props.columns, column => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
(column) => extend(column, { orderByField: column.orderByField || column.field }) column => extend(column, { orderByField: column.orderByField || column.field })
), ),
(column, index) => { (column, index) => {
// Bind click events only to sortable columns
const onHeaderCell = column.sorter ? () => ({ onClick: () => toggleSorting(column.orderByField) }) : null;
// Wrap render function to pass correct arguments // Wrap render function to pass correct arguments
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity; const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
@@ -144,13 +146,14 @@ export default class ItemsTable extends React.Component {
key: "column" + index, key: "column" + index,
dataIndex: ["item", column.field], dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null, defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell,
render, render,
}); });
} }
); );
} }
getRowKey = (record) => { getRowKey = record => {
const { rowKey } = this.props; const { rowKey } = this.props;
if (rowKey) { if (rowKey) {
if (isFunction(rowKey)) { if (isFunction(rowKey)) {
@@ -169,43 +172,22 @@ export default class ItemsTable extends React.Component {
// Bind events only if `onRowClick` specified // Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick) const onTableRow = isFunction(this.props.onRowClick)
? (row) => ({ ? row => ({
onClick: (event) => { onClick: event => {
this.props.onRowClick(event, row.item); this.props.onRowClick(event, row.item);
}, },
}) })
: null; : null;
const onChange = (pagination, filters, sorter, extra) => {
const action = extra?.action;
if (action === "sort") {
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
if (!propsColumn.sorter) {
return;
}
let orderByField = propsColumn.orderByField;
const orderByReverse = sorter.order === "descend";
if (orderByReverse === undefined) {
orderByField = null;
}
if (this.props.setSorting) {
this.props.setSorting(orderByField, orderByReverse);
} else {
this.props.toggleSorting(orderByField);
}
}
};
const { showHeader } = this.props; const { showHeader } = this.props;
if (this.props.loading) { if (this.props.loading) {
if (isEmpty(tableDataProps.dataSource)) { if (isEmpty(tableDataProps.dataSource)) {
tableDataProps.columns = tableDataProps.columns.map((column) => ({ tableDataProps.columns = tableDataProps.columns.map(column => ({
...column, ...column,
sorter: false, sorter: false,
render: () => <Skeleton active paragraph={false} />, render: () => <Skeleton active paragraph={false} />,
})); }));
tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` })); tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
} else { } else {
tableDataProps.loading = { indicator: null }; tableDataProps.loading = { indicator: null };
} }
@@ -218,7 +200,6 @@ export default class ItemsTable extends React.Component {
rowKey={this.getRowKey} rowKey={this.getRowKey}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
onChange={onChange}
data-test={this.props["data-test"]} data-test={this.props["data-test"]}
{...tableDataProps} {...tableDataProps}
/> />

View File

@@ -47,14 +47,6 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
return ( return (
<div {...props}> <div {...props}>
<div className="schema-list-item"> <div className="schema-list-item">
<Tooltip
title={item.description}
mouseEnterDelay={0}
mouseLeaveDelay={0}
placement="rightTop"
trigger={item.description ? "hover" : ""}
overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton className="table-name" onClick={onToggle}> <PlainButton className="table-name" onClick={onToggle}>
<i className="fa fa-table m-r-5" aria-hidden="true" /> <i className="fa fa-table m-r-5" aria-hidden="true" />
<strong> <strong>
@@ -62,15 +54,13 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
{!isNil(item.size) && <span> ({item.size})</span>} {!isNil(item.size) && <span> ({item.size})</span>}
</strong> </strong>
</PlainButton> </PlainButton>
</Tooltip>
<Tooltip <Tooltip
title="Insert table name into query text" title="Insert table name into query text"
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="topRight" placement="topRight"
arrowPointAtCenter arrowPointAtCenter>
> <PlainButton className="copy-to-editor" onClick={e => handleSelect(e, item.name)}>
<PlainButton className="copy-to-editor" onClick={(e) => handleSelect(e, item.name)}>
<i className="fa fa-angle-double-right" aria-hidden="true" /> <i className="fa fa-angle-double-right" aria-hidden="true" />
</PlainButton> </PlainButton>
</Tooltip> </Tooltip>
@@ -80,23 +70,16 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
{item.loading ? ( {item.loading ? (
<div className="table-open">Loading...</div> <div className="table-open">Loading...</div>
) : ( ) : (
map(item.columns, (column) => { map(item.columns, column => {
const columnName = get(column, "name"); const columnName = get(column, "name");
const columnType = get(column, "type"); const columnType = get(column, "type");
const columnDescription = get(column, "description");
return ( return (
<Tooltip <Tooltip
title={"Insert column name into query text" + (columnDescription ? "\n" + columnDescription : "")} title="Insert column name into query text"
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="rightTop" placement="rightTop">
overlayStyle={{ whiteSpace: "pre-line" }} <PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}>
>
<PlainButton
key={columnName}
className="table-open-item"
onClick={(e) => handleSelect(e, columnName)}
>
<div> <div>
{columnName} {columnType && <span className="column-type">{columnType}</span>} {columnName} {columnType && <span className="column-type">{columnType}</span>}
</div> </div>
@@ -185,7 +168,7 @@ export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onIt
} }
export function applyFilterOnSchema(schema, filterString) { export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), (s) => s.length > 0); const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0);
// Empty string: return original schema // Empty string: return original schema
if (filters.length === 0) { if (filters.length === 0) {
@@ -198,9 +181,9 @@ export function applyFilterOnSchema(schema, filterString) {
const columnFilter = filters[0]; const columnFilter = filters[0];
return filter( return filter(
schema, schema,
(item) => item =>
includes(item.name.toLowerCase(), nameFilter) || includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter)) some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter))
); );
} }
@@ -208,11 +191,11 @@ export function applyFilterOnSchema(schema, filterString) {
const nameFilter = filters[0]; const nameFilter = filters[0];
const columnFilter = filters[1]; const columnFilter = filters[1];
return filter( return filter(
map(schema, (item) => { map(schema, item => {
if (includes(item.name.toLowerCase(), nameFilter)) { if (includes(item.name.toLowerCase(), nameFilter)) {
item = { item = {
...item, ...item,
columns: filter(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter)), columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)),
}; };
return item.columns.length > 0 ? item : null; return item.columns.length > 0 ? item : null;
} }
@@ -260,7 +243,7 @@ export default function SchemaBrowser({
placeholder="Search schema..." placeholder="Search schema..."
aria-label="Search schema" aria-label="Search schema"
disabled={schema.length === 0} disabled={schema.length === 0}
onChange={(event) => handleFilterChange(event.target.value)} onChange={event => handleFilterChange(event.target.value)}
/> />
<Tooltip title="Refresh Schema"> <Tooltip title="Refresh Schema">

View File

@@ -59,7 +59,6 @@ function wrapComponentWithSettings(WrappedComponent) {
"dateTimeFormat", "dateTimeFormat",
"integerFormat", "integerFormat",
"floatFormat", "floatFormat",
"nullValue",
"booleanValues", "booleanValues",
"tableCellMaxJSONSize", "tableCellMaxJSONSize",
"allowCustomJSVisualizations", "allowCustomJSVisualizations",

View File

@@ -1,13 +1,13 @@
export default { export default {
columns: 12, // grid columns count columns: 6, // grid columns count
rowHeight: 50, // grid row height (incl. bottom padding) rowHeight: 50, // grid row height (incl. bottom padding)
margins: 15, // widget margins margins: 15, // widget margins
mobileBreakPoint: 800, mobileBreakPoint: 800,
// defaults for widgets // defaults for widgets
defaultSizeX: 6, defaultSizeX: 3,
defaultSizeY: 3, defaultSizeY: 3,
minSizeX: 2, minSizeX: 1,
maxSizeX: 12, maxSizeX: 6,
minSizeY: 2, minSizeY: 1,
maxSizeY: 1000, maxSizeY: 1000,
}; };

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" translate="no"> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@@ -81,19 +81,12 @@ function DashboardListExtraActions(props) {
} }
function DashboardList({ controller }) { function DashboardList({ controller }) {
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, usedListColumns, DashboardListExtraActions); } = useItemsListExtraActions(controller, listColumns, DashboardListExtraActions);
return ( return (
<div className="page-dashboard-list"> <div className="page-dashboard-list">
@@ -146,9 +139,9 @@ function DashboardList({ controller }) {
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })} onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={(page) => controller.updatePagination({ page })} onChange={page => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -177,10 +170,10 @@ const DashboardListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return (item) => new Dashboard(item); return item => new Dashboard(item);
}, },
}), }),
({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true }) () => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -188,7 +181,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards", path: "/dashboards",
title: "Dashboards", title: "Dashboards",
render: (pageProps) => <DashboardListPage {...pageProps} currentPage="all" />, render: pageProps => <DashboardListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -196,7 +189,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/favorites", path: "/dashboards/favorites",
title: "Favorite Dashboards", title: "Favorite Dashboards",
render: (pageProps) => <DashboardListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />, render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />,
}) })
); );
routes.register( routes.register(
@@ -204,6 +197,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/my", path: "/dashboards/my",
title: "My Dashboards", title: "My Dashboards",
render: (pageProps) => <DashboardListPage {...pageProps} currentPage="my" />, render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -31,8 +31,7 @@ function DashboardSettings({ dashboardConfiguration }) {
<Checkbox <Checkbox
checked={!!dashboard.dashboard_filters_enabled} checked={!!dashboard.dashboard_filters_enabled}
onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })} onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })}
data-test="DashboardFiltersCheckbox" data-test="DashboardFiltersCheckbox">
>
Use Dashboard Level Filters Use Dashboard Level Filters
</Checkbox> </Checkbox>
</div> </div>
@@ -91,9 +90,9 @@ function DashboardComponent(props) {
const [pageContainer, setPageContainer] = useState(null); const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({}); const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = (parameters) => { const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name"); const paramOrder = map(parameters, "name");
updateDashboard({ options: { ...dashboard.options, globalParamOrder: paramOrder } }); updateDashboard({ options: { globalParamOrder: paramOrder } });
}; };
useEffect(() => { useEffect(() => {
@@ -176,7 +175,7 @@ function DashboardPage({ dashboardSlug, dashboardId, onError }) {
useEffect(() => { useEffect(() => {
Dashboard.get({ id: dashboardId, slug: dashboardSlug }) Dashboard.get({ id: dashboardId, slug: dashboardSlug })
.then((dashboardData) => { .then(dashboardData => {
recordEvent("view", "dashboard", dashboardData.id); recordEvent("view", "dashboard", dashboardData.id);
setDashboard(dashboardData); setDashboard(dashboardData);
@@ -208,7 +207,7 @@ routes.register(
"Dashboards.LegacyViewOrEdit", "Dashboards.LegacyViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboard/:dashboardSlug", path: "/dashboard/:dashboardSlug",
render: (pageProps) => <DashboardPage {...pageProps} />, render: pageProps => <DashboardPage {...pageProps} />,
}) })
); );
@@ -216,6 +215,6 @@ routes.register(
"Dashboards.ViewOrEdit", "Dashboards.ViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/:dashboardId([^-]+)(-.*)?", path: "/dashboards/:dashboardId([^-]+)(-.*)?",
render: (pageProps) => <DashboardPage {...pageProps} />, render: pageProps => <DashboardPage {...pageProps} />,
}) })
); );

View File

@@ -8,7 +8,7 @@
} }
> .container { > .container {
min-height: calc(100% - 95px); min-height: calc(100vh - 95px);
} }
.loading-message { .loading-message {

View File

@@ -22,7 +22,7 @@ import { DashboardStatusEnum } from "../hooks/useDashboard";
import "./DashboardHeader.less"; import "./DashboardHeader.less";
function getDashboardTags() { function getDashboardTags() {
return getTags("api/dashboards/tags").then((tags) => map(tags, (t) => t.name)); return getTags("api/dashboards/tags").then(tags => map(tags, t => t.name));
} }
function buttonType(value) { function buttonType(value) {
@@ -38,7 +38,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
<h3> <h3>
<EditInPlace <EditInPlace
isEditable={editingLayout} isEditable={editingLayout}
onDone={(name) => updateDashboard({ name })} onDone={name => updateDashboard({ name })}
value={dashboard.name} value={dashboard.name}
ignoreBlanks ignoreBlanks
/> />
@@ -53,7 +53,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
isArchived={dashboard.is_archived} isArchived={dashboard.is_archived}
canEdit={canEditDashboard} canEdit={canEditDashboard}
getAvailableTags={getDashboardTags} getAvailableTags={getDashboardTags}
onEdit={(tags) => updateDashboard({ tags })} onEdit={tags => updateDashboard({ tags })}
/> />
</div> </div>
); );
@@ -89,15 +89,14 @@ function RefreshButton({ dashboardConfiguration }) {
placement="bottomRight" placement="bottomRight"
overlay={ overlay={
<Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}> <Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}>
{refreshRateOptions.map((option) => ( {refreshRateOptions.map(option => (
<Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}> <Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}>
{durationHumanize(option)} {durationHumanize(option)}
</Menu.Item> </Menu.Item>
))} ))}
{refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>} {refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>}
</Menu> </Menu>
} }>
>
<Button className="icon-button hidden-xs" type={buttonType(refreshRate)}> <Button className="icon-button hidden-xs" type={buttonType(refreshRate)}>
<i className="fa fa-angle-down" aria-hidden="true" /> <i className="fa fa-angle-down" aria-hidden="true" />
<span className="sr-only">Split button!</span> <span className="sr-only">Split button!</span>
@@ -167,8 +166,7 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
<PlainButton onClick={archive}>Archive</PlainButton> <PlainButton onClick={archive}>Archive</PlainButton>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
} }>
>
<Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions"> <Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions">
<EllipsisOutlinedIcon rotate={90} aria-hidden="true" /> <EllipsisOutlinedIcon rotate={90} aria-hidden="true" />
</Button> </Button>
@@ -218,8 +216,7 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(fullscreen)} type={buttonType(fullscreen)}
className="icon-button m-l-5" className="icon-button m-l-5"
onClick={toggleFullscreen} onClick={toggleFullscreen}
aria-label="Toggle fullscreen display" aria-label="Toggle fullscreen display">
>
<i className="zmdi zmdi-fullscreen" aria-hidden="true" /> <i className="zmdi zmdi-fullscreen" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -232,8 +229,7 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(dashboard.publicAccessEnabled)} type={buttonType(dashboard.publicAccessEnabled)}
onClick={showShareDashboardDialog} onClick={showShareDashboardDialog}
data-test="OpenShareForm" data-test="OpenShareForm"
aria-label="Share" aria-label="Share">
>
<i className="zmdi zmdi-share" aria-hidden="true" /> <i className="zmdi zmdi-share" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -256,11 +252,7 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
doneBtnClickedWhileSaving, doneBtnClickedWhileSaving,
dashboardStatus, dashboardStatus,
retrySaveDashboardLayout, retrySaveDashboardLayout,
saveDashboardParameters,
} = dashboardConfiguration; } = dashboardConfiguration;
const handleDoneEditing = () => {
saveDashboardParameters().then(() => setEditingLayout(false));
};
let status; let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) { if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = <span className="save-status">Saved</span>; status = <span className="save-status">Saved</span>;
@@ -285,7 +277,7 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
Retry Retry
</Button> </Button>
) : ( ) : (
<Button loading={doneBtnClickedWhileSaving} type="primary" onClick={handleDoneEditing}> <Button loading={doneBtnClickedWhileSaving} type="primary" onClick={() => setEditingLayout(false)}>
{!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing {!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing
</Button> </Button>
)} )}

View File

@@ -22,12 +22,12 @@ export { DashboardStatusEnum } from "./useEditModeHandler";
function getAffectedWidgets(widgets, updatedParameters = []) { function getAffectedWidgets(widgets, updatedParameters = []) {
return !isEmpty(updatedParameters) return !isEmpty(updatedParameters)
? widgets.filter((widget) => ? widgets.filter(widget =>
Object.values(widget.getParameterMappings()) Object.values(widget.getParameterMappings())
.filter(({ type }) => type === "dashboard-level") .filter(({ type }) => type === "dashboard-level")
.some(({ mapTo }) => .some(({ mapTo }) =>
includes( includes(
updatedParameters.map((p) => p.name), updatedParameters.map(p => p.name),
mapTo mapTo
) )
) )
@@ -50,7 +50,7 @@ function useDashboard(dashboardData) {
[dashboard] [dashboard]
); );
const hasOnlySafeQueries = useMemo( const hasOnlySafeQueries = useMemo(
() => every(dashboard.widgets, (w) => (w.getQuery() ? w.getQuery().is_safe : true)), () => every(dashboard.widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)),
[dashboard] [dashboard]
); );
@@ -67,19 +67,19 @@ function useDashboard(dashboardData) {
const updateDashboard = useCallback( const updateDashboard = useCallback(
(data, includeVersion = true) => { (data, includeVersion = true) => {
setDashboard((currentDashboard) => extend({}, currentDashboard, data)); setDashboard(currentDashboard => extend({}, currentDashboard, data));
data = { ...data, id: dashboard.id }; data = { ...data, id: dashboard.id };
if (includeVersion) { if (includeVersion) {
data = { ...data, version: dashboard.version }; data = { ...data, version: dashboard.version };
} }
return Dashboard.save(data) return Dashboard.save(data)
.then((updatedDashboard) => { .then(updatedDashboard => {
setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, keys(data)))); setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, keys(data))));
if (has(data, "name")) { if (has(data, "name")) {
location.setPath(url.parse(updatedDashboard.url).pathname, true); location.setPath(url.parse(updatedDashboard.url).pathname, true);
} }
}) })
.catch((error) => { .catch(error => {
const status = get(error, "response.status"); const status = get(error, "response.status");
if (status === 403) { if (status === 403) {
notification.error("Dashboard update failed", "Permission Denied."); notification.error("Dashboard update failed", "Permission Denied.");
@@ -102,25 +102,25 @@ function useDashboard(dashboardData) {
const loadWidget = useCallback((widget, forceRefresh = false) => { const loadWidget = useCallback((widget, forceRefresh = false) => {
widget.getParametersDefs(); // Force widget to read parameters values from URL widget.getParametersDefs(); // Force widget to read parameters values from URL
setDashboard((currentDashboard) => extend({}, currentDashboard)); setDashboard(currentDashboard => extend({}, currentDashboard));
return widget return widget
.load(forceRefresh) .load(forceRefresh)
.catch((error) => { .catch(error => {
// QueryResultErrors are expected // QueryResultErrors are expected
if (error instanceof QueryResultError) { if (error instanceof QueryResultError) {
return; return;
} }
return Promise.reject(error); return Promise.reject(error);
}) })
.finally(() => setDashboard((currentDashboard) => extend({}, currentDashboard))); .finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
}, []); }, []);
const refreshWidget = useCallback((widget) => loadWidget(widget, true), [loadWidget]); const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]);
const removeWidget = useCallback((widgetId) => { const removeWidget = useCallback(widgetId => {
setDashboard((currentDashboard) => setDashboard(currentDashboard =>
extend({}, currentDashboard, { extend({}, currentDashboard, {
widgets: currentDashboard.widgets.filter((widget) => widget.id !== undefined && widget.id !== widgetId), widgets: currentDashboard.widgets.filter(widget => widget.id !== undefined && widget.id !== widgetId),
}) })
); );
}, []); }, []);
@@ -132,11 +132,11 @@ function useDashboard(dashboardData) {
(forceRefresh = false, updatedParameters = []) => { (forceRefresh = false, updatedParameters = []) => {
const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters); const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters);
const loadWidgetPromises = compact( const loadWidgetPromises = compact(
affectedWidgets.map((widget) => loadWidget(widget, forceRefresh).catch((error) => error)) affectedWidgets.map(widget => loadWidget(widget, forceRefresh).catch(error => error))
); );
return Promise.all(loadWidgetPromises).then(() => { return Promise.all(loadWidgetPromises).then(() => {
const queryResults = compact(map(dashboardRef.current.widgets, (widget) => widget.getQueryResult())); const queryResults = compact(map(dashboardRef.current.widgets, widget => widget.getQueryResult()));
const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search); const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search);
setFilters(updatedFilters); setFilters(updatedFilters);
}); });
@@ -145,7 +145,7 @@ function useDashboard(dashboardData) {
); );
const refreshDashboard = useCallback( const refreshDashboard = useCallback(
(updatedParameters) => { updatedParameters => {
if (!refreshing) { if (!refreshing) {
setRefreshing(true); setRefreshing(true);
loadDashboard(true, updatedParameters).finally(() => setRefreshing(false)); loadDashboard(true, updatedParameters).finally(() => setRefreshing(false));
@@ -154,30 +154,15 @@ function useDashboard(dashboardData) {
[refreshing, loadDashboard] [refreshing, loadDashboard]
); );
const saveDashboardParameters = useCallback(() => {
const currentDashboard = dashboardRef.current;
return updateDashboard({
options: {
...currentDashboard.options,
parameters: map(globalParameters, (p) => p.toSaveableObject()),
},
}).catch((error) => {
console.error("Failed to persist parameter values:", error);
notification.error("Parameter values could not be saved. Your changes may not be persisted.");
throw error;
});
}, [globalParameters, updateDashboard]);
const archiveDashboard = useCallback(() => { const archiveDashboard = useCallback(() => {
recordEvent("archive", "dashboard", dashboard.id); recordEvent("archive", "dashboard", dashboard.id);
Dashboard.delete(dashboard).then((updatedDashboard) => Dashboard.delete(dashboard).then(updatedDashboard =>
setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"]))) setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"])))
); );
}, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps }, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
const showShareDashboardDialog = useCallback(() => { const showShareDashboardDialog = useCallback(() => {
const handleDialogClose = () => setDashboard((currentDashboard) => extend({}, currentDashboard)); const handleDialogClose = () => setDashboard(currentDashboard => extend({}, currentDashboard));
ShareDashboardDialog.showModal({ ShareDashboardDialog.showModal({
dashboard, dashboard,
@@ -190,8 +175,8 @@ function useDashboard(dashboardData) {
const showAddTextboxDialog = useCallback(() => { const showAddTextboxDialog = useCallback(() => {
TextboxDialog.showModal({ TextboxDialog.showModal({
isNew: true, isNew: true,
}).onClose((text) => }).onClose(text =>
dashboard.addWidget(text).then(() => setDashboard((currentDashboard) => extend({}, currentDashboard))) dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard)))
); );
}, [dashboard]); }, [dashboard]);
@@ -203,13 +188,13 @@ function useDashboard(dashboardData) {
.addWidget(visualization, { .addWidget(visualization, {
parameterMappings: editableMappingsToParameterMappings(parameterMappings), parameterMappings: editableMappingsToParameterMappings(parameterMappings),
}) })
.then((widget) => { .then(widget => {
const widgetsToSave = [ const widgetsToSave = [
widget, widget,
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
]; ];
return Promise.all(widgetsToSave.map((w) => w.save())).then(() => return Promise.all(widgetsToSave.map(w => w.save())).then(() =>
setDashboard((currentDashboard) => extend({}, currentDashboard)) setDashboard(currentDashboard => extend({}, currentDashboard))
); );
}) })
); );
@@ -253,7 +238,6 @@ function useDashboard(dashboardData) {
setRefreshRate, setRefreshRate,
disableRefreshRate, disableRefreshRate,
...editModeHandler, ...editModeHandler,
saveDashboardParameters,
gridDisabled, gridDisabled,
setGridDisabled, setGridDisabled,
fullscreen, fullscreen,

View File

@@ -15,7 +15,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
resource resource
.favorites({ order: "-starred_at" }) .favorites()
.then(({ results }) => setItems(results)) .then(({ results }) => setItems(results))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [resource]); }, [resource]);
@@ -28,7 +28,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
</div> </div>
{!isEmpty(items) && ( {!isEmpty(items) && (
<div role="list" className="list-group"> <div role="list" className="list-group">
{items.map((item) => ( {items.map(item => (
<Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}> <Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
<i className="fa fa-star" aria-hidden="true" /> <i className="fa fa-star" aria-hidden="true" />
@@ -61,7 +61,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Dashboards" title="Favorite Dashboards"
resource={Dashboard} resource={Dashboard}
itemUrl={(dashboard) => dashboard.url} itemUrl={dashboard => dashboard.url}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
@@ -76,7 +76,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Queries" title="Favorite Queries"
resource={Query} resource={Query}
itemUrl={(query) => `queries/${query.id}`} itemUrl={query => `queries/${query.id}`}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import cx from "classnames"; import cx from "classnames";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession"; import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
@@ -20,7 +20,7 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import Layout from "@/components/layouts/ContentWithSidebar"; import Layout from "@/components/layouts/ContentWithSidebar";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { clientConfig, currentUser } from "@/services/auth"; import { currentUser } from "@/services/auth";
import location from "@/services/location"; import location from "@/services/location";
import routes from "@/services/routes"; import routes from "@/services/routes";
@@ -95,39 +95,25 @@ function QueriesList({ controller }) {
const controllerRef = useRef(); const controllerRef = useRef();
controllerRef.current = controller; controllerRef.current = controller;
const updateSearch = useCallback(
(searchTemm) => {
controller.updateSearch(searchTemm, { isServerSideFTS: !clientConfig.multiByteSearchEnabled });
},
[controller]
);
useEffect(() => { useEffect(() => {
const unlistenLocationChanges = location.listen((unused, action) => { const unlistenLocationChanges = location.listen((unused, action) => {
const searchTerm = location.search.q || ""; const searchTerm = location.search.q || "";
if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) { if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) {
updateSearch(searchTerm); controllerRef.current.updateSearch(searchTerm);
} }
}); });
return () => { return () => {
unlistenLocationChanges(); unlistenLocationChanges();
}; };
}, [updateSearch]); }, []);
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, usedListColumns, QueriesListExtraActions); } = useItemsListExtraActions(controller, listColumns, QueriesListExtraActions);
return ( return (
<div className="page-queries-list"> <div className="page-queries-list">
@@ -149,7 +135,7 @@ function QueriesList({ controller }) {
placeholder="Search Queries..." placeholder="Search Queries..."
label="Search queries" label="Search queries"
value={controller.searchTerm} value={controller.searchTerm}
onChange={updateSearch} onChange={controller.updateSearch}
/> />
<Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} /> <Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll /> <Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll />
@@ -174,15 +160,14 @@ function QueriesList({ controller }) {
orderByField={controller.orderByField} orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse} orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting} toggleSorting={controller.toggleSorting}
setSorting={controller.setSorting}
/> />
<Paginator <Paginator
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })} onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={(page) => controller.updatePagination({ page })} onChange={page => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -211,10 +196,10 @@ const QueriesListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return (item) => new Query(item); return item => new Query(item);
}, },
}), }),
({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true }) () => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -222,7 +207,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries", path: "/queries",
title: "Queries", title: "Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />, render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -230,7 +215,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/favorites", path: "/queries/favorites",
title: "Favorite Queries", title: "Favorite Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />, render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />,
}) })
); );
routes.register( routes.register(
@@ -238,7 +223,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/archive", path: "/queries/archive",
title: "Archived Queries", title: "Archived Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />, render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />,
}) })
); );
routes.register( routes.register(
@@ -246,6 +231,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/my", path: "/queries/my",
title: "My Queries", title: "My Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />, render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -10,9 +10,9 @@ export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
export function collectDashboardFilters(dashboard, queryResults, urlParams) { export function collectDashboardFilters(dashboard, queryResults, urlParams) {
const filters = {}; const filters = {};
_.each(queryResults, (queryResult) => { _.each(queryResults, queryResult => {
const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : []; const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : [];
_.each(queryFilters, (queryFilter) => { _.each(queryFilters, queryFilter => {
const hasQueryStringValue = _.has(urlParams, queryFilter.name); const hasQueryStringValue = _.has(urlParams, queryFilter.name);
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
@@ -44,7 +44,7 @@ function prepareWidgetsForDashboard(widgets) {
const defaultWidgetSizeY = const defaultWidgetSizeY =
Math.max( Math.max(
_.chain(widgets) _.chain(widgets)
.map((w) => w.options.position.sizeY) .map(w => w.options.position.sizeY)
.max() .max()
.value(), .value(),
20 20
@@ -55,11 +55,11 @@ function prepareWidgetsForDashboard(widgets) {
// 2. update position of widgets in each row - place it right below // 2. update position of widgets in each row - place it right below
// biggest widget from previous row // biggest widget from previous row
_.chain(widgets) _.chain(widgets)
.sortBy((widget) => widget.options.position.row) .sortBy(widget => widget.options.position.row)
.groupBy((widget) => widget.options.position.row) .groupBy(widget => widget.options.position.row)
.reduce((row, widgetsAtRow) => { .reduce((row, widgetsAtRow) => {
let height = 1; let height = 1;
_.each(widgetsAtRow, (widget) => { _.each(widgetsAtRow, widget => {
height = Math.max( height = Math.max(
height, height,
widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY
@@ -74,8 +74,8 @@ function prepareWidgetsForDashboard(widgets) {
.value(); .value();
// Sort widgets by updated column and row value // Sort widgets by updated column and row value
widgets = _.sortBy(widgets, (widget) => widget.options.position.col); widgets = _.sortBy(widgets, widget => widget.options.position.col);
widgets = _.sortBy(widgets, (widget) => widget.options.position.row); widgets = _.sortBy(widgets, widget => widget.options.position.row);
return widgets; return widgets;
} }
@@ -85,7 +85,7 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
// Find first free row for each column // Find first free row for each column
const bottomLine = _.chain(existingWidgets) const bottomLine = _.chain(existingWidgets)
.map((w) => { .map(w => {
const options = _.extend({}, w.options); const options = _.extend({}, w.options);
const position = _.extend({ row: 0, sizeY: 0 }, options.position); const position = _.extend({ row: 0, sizeY: 0 }, options.position);
return { return {
@@ -97,24 +97,21 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
height: position.sizeY, height: position.sizeY,
}; };
}) })
.reduce( .reduce((result, item) => {
(result, item) => {
const from = Math.max(item.left, 0); const from = Math.max(item.left, 0);
const to = Math.min(item.right, result.length + 1); const to = Math.min(item.right, result.length + 1);
for (let i = from; i < to; i += 1) { for (let i = from; i < to; i += 1) {
result[i] = Math.max(result[i], item.bottom); result[i] = Math.max(result[i], item.bottom);
} }
return result; return result;
}, }, _.map(new Array(dashboardGridOptions.columns), _.constant(0)))
_.map(new Array(dashboardGridOptions.columns), _.constant(0))
)
.value(); .value();
// Go through columns, pick them by count necessary to hold new block, // Go through columns, pick them by count necessary to hold new block,
// and calculate bottom-most free row per group. // and calculate bottom-most free row per group.
// Choose group with the top-most free row (comparing to other groups) // Choose group with the top-most free row (comparing to other groups)
return _.chain(_.range(0, dashboardGridOptions.columns - width + 1)) return _.chain(_.range(0, dashboardGridOptions.columns - width + 1))
.map((col) => ({ .map(col => ({
col, col,
row: _.chain(bottomLine) row: _.chain(bottomLine)
.slice(col, col + width) .slice(col, col + width)
@@ -136,7 +133,7 @@ export function Dashboard(dashboard) {
} }
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, (widget) => new Widget(widget))); return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
} }
function transformSingle(dashboard) { function transformSingle(dashboard) {
@@ -157,7 +154,7 @@ function transformResponse(data) {
return data; return data;
} }
const saveOrCreateUrl = (data) => (data.id ? `api/dashboards/${data.id}` : "api/dashboards"); const saveOrCreateUrl = data => (data.id ? `api/dashboards/${data.id}` : "api/dashboards");
const DashboardService = { const DashboardService = {
get: ({ id, slug }) => { get: ({ id, slug }) => {
const params = {}; const params = {};
@@ -167,12 +164,12 @@ const DashboardService = {
return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse); return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse);
}, },
getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse), getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse),
save: (data) => axios.post(saveOrCreateUrl(data), data).then(transformResponse), save: data => axios.post(saveOrCreateUrl(data), data).then(transformResponse),
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse), delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: (params) => axios.get("api/dashboards", { params }).then(transformResponse), query: params => axios.get("api/dashboards", { params }).then(transformResponse),
recent: (params) => axios.get("api/dashboards/recent", { params }).then(transformResponse), recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse),
myDashboards: (params) => axios.get("api/dashboards/my", { params }).then(transformResponse), myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse),
favorites: (params) => axios.get("api/dashboards/favorites", { params }).then(transformResponse), favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`), favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`), unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse), fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
@@ -190,13 +187,13 @@ Dashboard.prototype.canEdit = function canEdit() {
Dashboard.prototype.getParametersDefs = function getParametersDefs() { Dashboard.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {}; const globalParams = {};
const queryParams = location.search; const queryParams = location.search;
_.each(this.widgets, (widget) => { _.each(this.widgets, widget => {
if (widget.getQuery()) { if (widget.getQuery()) {
const mappings = widget.getParameterMappings(); const mappings = widget.getParameterMappings();
widget widget
.getQuery() .getQuery()
.getParametersDefs(false) .getParametersDefs(false)
.forEach((param) => { .forEach(param => {
const mapping = mappings[param.name]; const mapping = mappings[param.name];
if (mapping.type === Widget.MappingType.DashboardLevel) { if (mapping.type === Widget.MappingType.DashboardLevel) {
// create global param // create global param
@@ -213,19 +210,15 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
}); });
} }
}); });
const mergedValues = {
..._.mapValues(globalParams, (p) => p.value),
...Object.fromEntries((this.options.parameters || []).map((param) => [param.name, param.value])),
};
const resultingGlobalParams = _.values( const resultingGlobalParams = _.values(
_.each(globalParams, (param) => { _.each(globalParams, param => {
param.setValue(mergedValues[param.name]); // apply merged value param.setValue(param.value); // apply global param value to all locals
param.fromUrlParams(queryParams); // allow param-specific parsing logic param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
}) })
); );
// order dashboard params using paramOrder // order dashboard params using paramOrder
return _.sortBy(resultingGlobalParams, (param) => return _.sortBy(resultingGlobalParams, param =>
_.includes(this.options.globalParamOrder, param.name) _.includes(this.options.globalParamOrder, param.name)
? _.indexOf(this.options.globalParamOrder, param.name) ? _.indexOf(this.options.globalParamOrder, param.name)
: _.size(this.options.globalParamOrder) : _.size(this.options.globalParamOrder)

View File

@@ -9,7 +9,7 @@ function normalizeLocation(rawLocation) {
const result = {}; const result = {};
result.path = pathname; result.path = pathname;
result.search = mapValues(qs.parse(search), (value) => (isNil(value) ? true : value)); result.search = mapValues(qs.parse(search), value => (isNil(value) ? true : value));
result.hash = trimStart(hash, "#"); result.hash = trimStart(hash, "#");
result.url = `${pathname}${search}${hash}`; result.url = `${pathname}${search}${hash}`;
@@ -27,7 +27,7 @@ const location = {
confirmChange(handler) { confirmChange(handler) {
if (isFunction(handler)) { if (isFunction(handler)) {
return history.block((nextLocation) => { return history.block(nextLocation => {
return handler(normalizeLocation(nextLocation), location); return handler(normalizeLocation(nextLocation), location);
}); });
} else { } else {
@@ -60,18 +60,12 @@ const location = {
// serialize search and keep existing search parameters (!) // serialize search and keep existing search parameters (!)
if (isObject(newLocation.search)) { if (isObject(newLocation.search)) {
newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil); newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil);
newLocation.search = mapValues(newLocation.search, (value) => (value === true ? null : value)); newLocation.search = mapValues(newLocation.search, value => (value === true ? null : value));
newLocation.search = qs.stringify(newLocation.search); newLocation.search = qs.stringify(newLocation.search);
} }
} }
if (replace) { if (replace) {
if (
newLocation.pathname !== location.path ||
newLocation.search !== qs.stringify(location.search) ||
newLocation.hash !== location.hash
) {
history.replace(newLocation); history.replace(newLocation);
}
} else { } else {
history.push(newLocation); history.push(newLocation);
} }

View File

@@ -17,9 +17,7 @@ const DYNAMIC_PREFIX = "d_";
* @param now {function(): moment.Moment=} moment - defaults to now * @param now {function(): moment.Moment=} moment - defaults to now
* @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]} * @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]}
*/ */
const untilNow = const untilNow = (from, now = () => moment()) => (withNow = true) => [from(), withNow ? now() : undefined];
(from, now = () => moment()) =>
(withNow = true) => [from(), withNow ? now() : undefined];
const DYNAMIC_DATE_RANGES = { const DYNAMIC_DATE_RANGES = {
today: { today: {
@@ -28,7 +26,14 @@ const DYNAMIC_DATE_RANGES = {
}, },
yesterday: { yesterday: {
name: "Yesterday", name: "Yesterday",
value: () => [moment().subtract(1, "day").startOf("day"), moment().subtract(1, "day").endOf("day")], value: () => [
moment()
.subtract(1, "day")
.startOf("day"),
moment()
.subtract(1, "day")
.endOf("day"),
],
}, },
this_week: { this_week: {
name: "This week", name: "This week",
@@ -44,15 +49,36 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_week: { last_week: {
name: "Last week", name: "Last week",
value: () => [moment().subtract(1, "week").startOf("week"), moment().subtract(1, "week").endOf("week")], value: () => [
moment()
.subtract(1, "week")
.startOf("week"),
moment()
.subtract(1, "week")
.endOf("week"),
],
}, },
last_month: { last_month: {
name: "Last month", name: "Last month",
value: () => [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")], value: () => [
moment()
.subtract(1, "month")
.startOf("month"),
moment()
.subtract(1, "month")
.endOf("month"),
],
}, },
last_year: { last_year: {
name: "Last year", name: "Last year",
value: () => [moment().subtract(1, "year").startOf("year"), moment().subtract(1, "year").endOf("year")], value: () => [
moment()
.subtract(1, "year")
.startOf("year"),
moment()
.subtract(1, "year")
.endOf("year"),
],
}, },
last_hour: { last_hour: {
name: "Last hour", name: "Last hour",
@@ -68,31 +94,63 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_7_days: { last_7_days: {
name: "Last 7 days", name: "Last 7 days",
value: untilNow(() => moment().subtract(7, "days").startOf("day")), value: untilNow(
() =>
moment()
.subtract(7, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_14_days: { last_14_days: {
name: "Last 14 days", name: "Last 14 days",
value: untilNow(() => moment().subtract(14, "days").startOf("day")), value: untilNow(
() =>
moment()
.subtract(14, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_30_days: { last_30_days: {
name: "Last 30 days", name: "Last 30 days",
value: untilNow(() => moment().subtract(30, "days").startOf("day")), value: untilNow(
() =>
moment()
.subtract(30, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_60_days: { last_60_days: {
name: "Last 60 days", name: "Last 60 days",
value: untilNow(() => moment().subtract(60, "days").startOf("day")), value: untilNow(
() =>
moment()
.subtract(60, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_90_days: { last_90_days: {
name: "Last 90 days", name: "Last 90 days",
value: untilNow(() => moment().subtract(90, "days").startOf("day")), value: untilNow(
() =>
moment()
.subtract(90, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_12_months: { last_12_months: {
name: "Last 12 months", name: "Last 12 months",
value: untilNow(() => moment().subtract(12, "months").startOf("day")), value: untilNow(
}, () =>
last_10_years: { moment()
name: "Last 10 years", .subtract(12, "months")
value: untilNow(() => moment().subtract(10, "years").startOf("day")), .startOf("day"),
() => moment().endOf("day")
),
}, },
}; };
@@ -106,7 +164,7 @@ export function isDynamicDateRangeString(value) {
} }
export function getDynamicDateRangeStringFromName(dynamicRangeName) { export function getDynamicDateRangeStringFromName(dynamicRangeName) {
const key = findKey(DYNAMIC_DATE_RANGES, (range) => range.name === dynamicRangeName); const key = findKey(DYNAMIC_DATE_RANGES, range => range.name === dynamicRangeName);
return key ? DYNAMIC_PREFIX + key : undefined; return key ? DYNAMIC_PREFIX + key : undefined;
} }
@@ -175,7 +233,7 @@ class DateRangeParameter extends Parameter {
getExecutionValue() { getExecutionValue() {
if (this.hasDynamicValue) { if (this.hasDynamicValue) {
const format = (date) => date.format(DATETIME_FORMATS[this.type]); const format = date => date.format(DATETIME_FORMATS[this.type]);
const [start, end] = this.normalizedValue.value().map(format); const [start, end] = this.normalizedValue.value().map(format);
return { start, end }; return { start, end };
} }

View File

@@ -58,7 +58,7 @@ class Parameter {
updateLocals() { updateLocals() {
if (isArray(this.locals)) { if (isArray(this.locals)) {
each(this.locals, (local) => { each(this.locals, local => {
local.setValue(this.value); local.setValue(this.value);
}); });
} }
@@ -117,7 +117,7 @@ class Parameter {
/** Get a saveable version of the Parameter by omitting unnecessary props */ /** Get a saveable version of the Parameter by omitting unnecessary props */
toSaveableObject() { toSaveableObject() {
return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId", "locals"]); return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId"]);
} }
} }

View File

@@ -9,7 +9,7 @@ const logger = debug("redash:services:QueryResult");
const filterTypes = ["filter", "multi-filter", "multiFilter"]; const filterTypes = ["filter", "multi-filter", "multiFilter"];
function defer() { function defer() {
const result = { onStatusChange: (status) => {} }; const result = { onStatusChange: status => {} };
result.promise = new Promise((resolve, reject) => { result.promise = new Promise((resolve, reject) => {
result.resolve = resolve; result.resolve = resolve;
result.reject = reject; result.reject = reject;
@@ -40,13 +40,13 @@ function getColumnNameWithoutType(column) {
} }
function getColumnFriendlyName(column) { function getColumnFriendlyName(column) {
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()); return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase());
} }
const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results"); const createOrSaveUrl = data => (data.id ? `api/query_results/${data.id}` : "api/query_results");
const QueryResultResource = { const QueryResultResource = {
get: ({ id }) => axios.get(`api/query_results/${id}`), get: ({ id }) => axios.get(`api/query_results/${id}`),
post: (data) => axios.post(createOrSaveUrl(data), data), post: data => axios.post(createOrSaveUrl(data), data),
}; };
export const ExecutionStatus = { export const ExecutionStatus = {
@@ -97,11 +97,11 @@ function handleErrorResponse(queryResult, error) {
} }
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
export function fetchDataFromJob(jobId, interval = 1000) { export function fetchDataFromJob(jobId, interval = 1000) {
return axios.get(`api/jobs/${jobId}`).then((data) => { return axios.get(`api/jobs/${jobId}`).then(data => {
const status = statuses[data.job.status]; const status = statuses[data.job.status];
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) { if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
return sleep(interval).then(() => fetchDataFromJob(data.job.id)); return sleep(interval).then(() => fetchDataFromJob(data.job.id));
@@ -146,7 +146,7 @@ class QueryResult {
// TODO: we should stop manipulating incoming data, and switch to relaying // TODO: we should stop manipulating incoming data, and switch to relaying
// on the column type set by the backend. This logic is prone to errors, // on the column type set by the backend. This logic is prone to errors,
// and better be removed. Kept for now, for backward compatability. // and better be removed. Kept for now, for backward compatability.
each(this.query_result.data.rows, (row) => { each(this.query_result.data.rows, row => {
forOwn(row, (v, k) => { forOwn(row, (v, k) => {
let newType = null; let newType = null;
if (isNumber(v)) { if (isNumber(v)) {
@@ -173,7 +173,7 @@ class QueryResult {
}); });
}); });
each(this.query_result.data.columns, (column) => { each(this.query_result.data.columns, column => {
column.name = "" + column.name; column.name = "" + column.name;
if (columnTypes[column.name]) { if (columnTypes[column.name]) {
if (column.type == null || column.type === "string") { if (column.type == null || column.type === "string") {
@@ -265,14 +265,14 @@ class QueryResult {
getColumnNames() { getColumnNames() {
if (this.columnNames === undefined && this.query_result.data) { if (this.columnNames === undefined && this.query_result.data) {
this.columnNames = this.query_result.data.columns.map((v) => v.name); this.columnNames = this.query_result.data.columns.map(v => v.name);
} }
return this.columnNames; return this.columnNames;
} }
getColumnFriendlyNames() { getColumnFriendlyNames() {
return this.getColumnNames().map((col) => getColumnFriendlyName(col)); return this.getColumnNames().map(col => getColumnFriendlyName(col));
} }
getTruncated() { getTruncated() {
@@ -286,7 +286,7 @@ class QueryResult {
const filters = []; const filters = [];
this.getColumns().forEach((col) => { this.getColumns().forEach(col => {
const name = col.name; const name = col.name;
const type = name.split("::")[1] || name.split("__")[1]; const type = name.split("::")[1] || name.split("__")[1];
if (includes(filterTypes, type)) { if (includes(filterTypes, type)) {
@@ -302,8 +302,8 @@ class QueryResult {
} }
}, this); }, this);
this.getRawData().forEach((row) => { this.getRawData().forEach(row => {
filters.forEach((filter) => { filters.forEach(filter => {
filter.values.push(row[filter.name]); filter.values.push(row[filter.name]);
if (filter.values.length === 1) { if (filter.values.length === 1) {
if (filter.multiple) { if (filter.multiple) {
@@ -315,8 +315,8 @@ class QueryResult {
}); });
}); });
filters.forEach((filter) => { filters.forEach(filter => {
filter.values = uniqBy(filter.values, (v) => { filter.values = uniqBy(filter.values, v => {
if (moment.isMoment(v)) { if (moment.isMoment(v)) {
return v.unix(); return v.unix();
} }
@@ -345,12 +345,12 @@ class QueryResult {
axios axios
.get(`api/queries/${queryId}/results/${id}.json`) .get(`api/queries/${queryId}/results/${id}.json`)
.then((response) => { .then(response => {
// Success handler // Success handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
queryResult.update(response); queryResult.update(response);
}) })
.catch((error) => { .catch(error => {
// Error handler // Error handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
@@ -362,10 +362,10 @@ class QueryResult {
loadLatestCachedResult(queryId, parameters) { loadLatestCachedResult(queryId, parameters) {
axios axios
.post(`api/queries/${queryId}/results`, { queryId, parameters }) .post(`api/queries/${queryId}/results`, { queryId, parameters })
.then((response) => { .then(response => {
this.update(response); this.update(response);
}) })
.catch((error) => { .catch(error => {
handleErrorResponse(this, error); handleErrorResponse(this, error);
}); });
} }
@@ -375,11 +375,11 @@ class QueryResult {
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT); this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
QueryResultResource.get({ id: this.job.query_result_id }) QueryResultResource.get({ id: this.job.query_result_id })
.then((response) => { .then(response => {
this.update(response); this.update(response);
this.isLoadingResult = false; this.isLoadingResult = false;
}) })
.catch((error) => { .catch(error => {
if (tryCount === undefined) { if (tryCount === undefined) {
tryCount = 0; tryCount = 0;
} }
@@ -394,12 +394,9 @@ class QueryResult {
}); });
this.isLoadingResult = false; this.isLoadingResult = false;
} else { } else {
setTimeout( setTimeout(() => {
() => {
this.loadResult(tryCount + 1); this.loadResult(tryCount + 1);
}, }, 1000 * Math.pow(2, tryCount));
1000 * Math.pow(2, tryCount)
);
} }
}); });
} }
@@ -413,26 +410,19 @@ class QueryResult {
: axios.get(`api/queries/${query}/jobs/${this.job.id}`); : axios.get(`api/queries/${query}/jobs/${this.job.id}`);
request request
.then((jobResponse) => { .then(jobResponse => {
this.update(jobResponse); this.update(jobResponse);
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") { if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
loadResult(); loadResult();
} else if (this.getStatus() !== "failed") { } else if (this.getStatus() !== "failed") {
let waitTime; const waitTime = tryNumber > 10 ? 3000 : 500;
if (tryNumber <= 10) {
waitTime = 500;
} else if (tryNumber <= 50) {
waitTime = 1000;
} else {
waitTime = 3000;
}
setTimeout(() => { setTimeout(() => {
this.refreshStatus(query, parameters, tryNumber + 1); this.refreshStatus(query, parameters, tryNumber + 1);
}, waitTime); }, waitTime);
} }
}) })
.catch((error) => { .catch(error => {
logger("Connection error", error); logger("Connection error", error);
// TODO: use QueryResultError, or better yet: exception/reject of promise. // TODO: use QueryResultError, or better yet: exception/reject of promise.
this.update({ this.update({
@@ -461,14 +451,14 @@ class QueryResult {
axios axios
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge }) .post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
.then((response) => { .then(response => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(id, parameters); queryResult.refreshStatus(id, parameters);
} }
}) })
.catch((error) => { .catch(error => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });
@@ -491,14 +481,14 @@ class QueryResult {
} }
QueryResultResource.post(params) QueryResultResource.post(params)
.then((response) => { .then(response => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(query, parameters); queryResult.refreshStatus(query, parameters);
} }
}) })
.catch((error) => { .catch(error => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });

View File

@@ -23,7 +23,7 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardSaveButton").click(); cy.getByTestId("DashboardSaveButton").click();
}); });
cy.wait("@NewDashboard").then((xhr) => { cy.wait("@NewDashboard").then(xhr => {
const id = Cypress._.get(xhr, "response.body.id"); const id = Cypress._.get(xhr, "response.body.id");
assert.isDefined(id, "Dashboard api call returns id"); assert.isDefined(id, "Dashboard api call returns id");
@@ -40,9 +40,13 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardMoreButton").click(); 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.get(".label-tag-archived").should("exist");
cy.visit("/dashboards"); cy.visit("/dashboards");
@@ -56,7 +60,7 @@ describe("Dashboard", () => {
cy.server(); cy.server();
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard"); cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => { 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.visit(url);
cy.wait("@LoadDashboard"); cy.wait("@LoadDashboard");
cy.getByTestId(`DashboardId${id}Container`).should("exist"); cy.getByTestId(`DashboardId${id}Container`).should("exist");
@@ -76,7 +80,7 @@ describe("Dashboard", () => {
this.dashboardEditUrl = `/dashboards/${id}?edit`; this.dashboardEditUrl = `/dashboards/${id}?edit`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
@@ -89,20 +93,25 @@ describe("Dashboard", () => {
}); });
it("shows widgets with full width", () => { it("shows widgets with full width", () => {
cy.get("@textboxEl").should(($el) => { cy.get("@textboxEl").should($el => {
expect($el.width()).to.eq(770); expect($el.width()).to.eq(770);
}); });
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.get("@textboxEl").should(($el) => { cy.get("@textboxEl").should($el => {
expect($el.width()).to.eq(182); expect($el.width()).to.eq(378);
}); });
}); });
it("hides edit option", () => { 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.viewport(801 + menuWidth, 800);
cy.get("@editButton").should("be.visible"); cy.get("@editButton").should("be.visible");
@@ -111,7 +120,9 @@ describe("Dashboard", () => {
it("disables edit mode", function() { it("disables edit mode", function() {
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.visit(this.dashboardEditUrl); 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.viewport(800 + menuWidth, 800);
cy.contains("button", "Done Editing").should("not.exist"); cy.contains("button", "Done Editing").should("not.exist");

View File

@@ -23,7 +23,7 @@ describe("Dashboard Filters", () => {
name: "Query Filters", name: "Query Filters",
query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`, query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`,
}; };
cy.createDashboard("Dashboard Filters").then((dashboard) => { cy.createDashboard("Dashboard Filters").then(dashboard => {
createQueryAndAddWidget(dashboard.id, queryData) createQueryAndAddWidget(dashboard.id, queryData)
.as("widget1TestId") .as("widget1TestId")
.then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } })) .then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } }))
@@ -38,17 +38,20 @@ describe("Dashboard Filters", () => {
cy.getByTestId("DashboardFiltersCheckbox").click(); cy.getByTestId("DashboardFiltersCheckbox").click();
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter").find(".ant-select-selection-item").should("have.text", "a"); cy.getByTestId("FilterName-stage1::filter")
.find(".ant-select-selection-item")
.should("have.text", "a");
}); });
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]); expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click(); cy.getByTestId("FilterName-stage1::filter")
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "b").click(); cy.contains(".ant-select-item-option-content:visible", "b").click();
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
@@ -66,13 +69,14 @@ describe("Dashboard Filters", () => {
// assert that changing a global filter affects all widgets // assert that changing a global filter affects all widgets
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click(); cy.getByTestId("FilterName-stage1::filter")
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "c").click(); cy.contains(".ant-select-item-option-content:visible", "c").click();
[this.widget1TestId, this.widget2TestId].forEach((widgetTestId) => [this.widget1TestId, this.widget2TestId].forEach(widgetTestId =>
cy.getByTestId(widgetTestId).within(() => { cy.getByTestId(widgetTestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["c", "c", "c", "c"]); expectFirstColumnToHaveMembers(["c", "c", "c", "c"]);

View File

@@ -13,7 +13,7 @@ describe("Grid compliant widgets", () => {
this.dashboardUrl = `/dashboards/${id}`; this.dashboardUrl = `/dashboards/${id}`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
@@ -27,7 +27,7 @@ describe("Grid compliant widgets", () => {
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(30) .dragBy(90)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15 .should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
}); });
@@ -36,14 +36,14 @@ describe("Grid compliant widgets", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(110) .dragBy(110)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115 .should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(200) .dragBy(330)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215 .should("have.property", "left", 415 + menuWidth); // moved by 400, 15 -> 415
}); });
}); });
@@ -52,7 +52,7 @@ describe("Grid compliant widgets", () => {
cy.route("POST", "**/api/widgets/*").as("WidgetSave"); cy.route("POST", "**/api/widgets/*").as("WidgetSave");
editDashboard(); editDashboard();
cy.get("@textboxEl").dragBy(100); cy.get("@textboxEl").dragBy(330);
cy.wait("@WidgetSave"); cy.wait("@WidgetSave");
}); });
}); });
@@ -64,24 +64,24 @@ describe("Grid compliant widgets", () => {
}); });
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 30) resizeBy(cy.get("@textboxEl"), 90)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 285); // no change, 285 -> 285 .should("eq", 585); // no change, 585 -> 585
}); });
it("moves one column when dragged over snap threshold", () => { it("moves one column when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 110) resizeBy(cy.get("@textboxEl"), 110)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 385); // resized by 200, 185 -> 385 .should("eq", 785); // resized by 200, 585 -> 785
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 400) resizeBy(cy.get("@textboxEl"), 400)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 685); // resized by 400, 285 -> 685 .should("eq", 985); // resized by 400, 585 -> 985
}); });
}); });
@@ -101,16 +101,16 @@ describe("Grid compliant widgets", () => {
resizeBy(cy.get("@textboxEl"), 0, 30) resizeBy(cy.get("@textboxEl"), 0, 30)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("height") .invoke("height")
.should("eq", 185); .should("eq", 185); // resized by 50, , 135 -> 185
}); });
it("shrinks to minimum", () => { it("shrinks to minimum", () => {
cy.get("@textboxEl") 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")) .then(() => cy.get("@textboxEl"))
.should(($el) => { .should($el => {
expect($el.width()).to.eq(185); // min textbox width expect($el.width()).to.eq(185); // min textbox width
expect($el.height()).to.eq(85); // min textbox height expect($el.height()).to.eq(35); // min textbox height
}); });
}); });
}); });

View File

@@ -12,7 +12,9 @@ describe("Textbox", () => {
}); });
const confirmDeletionInModal = () => { 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() {
@@ -30,7 +32,7 @@ describe("Textbox", () => {
it("removes textbox by X button", function() { it("removes textbox by X button", function() {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -46,12 +48,14 @@ describe("Textbox", () => {
it("removes textbox by menu", function() { it("removes textbox by menu", function() {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).within(() => { cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click(); cy.getByTestId("WidgetDropdownButtonMenu")
.contains("Remove from Dashboard")
.click();
confirmDeletionInModal(); confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist"); cy.getByTestId(elTestId).should("not.exist");
@@ -62,11 +66,11 @@ describe("Textbox", () => {
let elTestId1; let elTestId1;
cy.addTextbox(this.dashboardId, "txb 1") cy.addTextbox(this.dashboardId, "txb 1")
.then(getWidgetTestId) .then(getWidgetTestId)
.then((elTestId) => { .then(elTestId => {
elTestId1 = elTestId; elTestId1 = elTestId;
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId); return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
}) })
.then((elTestId2) => { .then(elTestId2 => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -96,7 +100,7 @@ describe("Textbox", () => {
it("edits textbox", function() { it("edits textbox", function() {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId) cy.getByTestId(elTestId)
.as("textboxEl") .as("textboxEl")
@@ -104,13 +108,17 @@ describe("Textbox", () => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click(); cy.getByTestId("WidgetDropdownButtonMenu")
.contains("Edit")
.click();
const newContent = "[edited]"; const newContent = "[edited]";
cy.getByTestId("TextboxDialog") cy.getByTestId("TextboxDialog")
.should("exist") .should("exist")
.within(() => { .within(() => {
cy.get("textarea").clear().type(newContent); cy.get("textarea")
.clear()
.type(newContent);
cy.contains("button", "Save").click(); cy.contains("button", "Save").click();
}); });
@@ -127,15 +135,15 @@ describe("Textbox", () => {
cy.addTextbox(id, "x", { position: txb1Pos }) cy.addTextbox(id, "x", { position: txb1Pos })
.then(() => cy.addTextbox(id, "x", { position: txb2Pos })) .then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
.then(getWidgetTestId) .then(getWidgetTestId)
.then((elTestId) => { .then(elTestId => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
return cy.getByTestId(elTestId); return cy.getByTestId(elTestId);
}) })
.should(($el) => { .should($el => {
const { top, left } = $el.offset(); const { top, left } = $el.offset();
expect(top).to.be.oneOf([162, 162.015625]); expect(top).to.be.oneOf([162, 162.015625]);
expect(left).to.eq(188); expect(left).to.eq(282);
expect($el.width()).to.eq(265); expect($el.width()).to.eq(545);
expect($el.height()).to.eq(185); expect($el.height()).to.eq(185);
}); });
}); });

View File

@@ -5,9 +5,8 @@ describe("Embedded Queries", () => {
}); });
it("is unavailable when public urls feature is disabled", () => { it("is unavailable when public urls feature is disabled", () => {
cy.createQuery({ query: "select name from users order by name" }).then((query) => { cy.createQuery({ query: "select name from users order by name" }).then(query => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -16,7 +15,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then((embedUrl) => { .then(embedUrl => {
// disable the feature // disable the feature
cy.updateOrgSettings({ disable_public_urls: true }); cy.updateOrgSettings({ disable_public_urls: true });
@@ -24,7 +23,9 @@ describe("Embedded Queries", () => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.getByTestId("QueryPageHeaderMoreButton").click(); cy.getByTestId("QueryPageHeaderMoreButton").click();
cy.get(".ant-dropdown-menu-item").should("exist").should("not.contain", "Show API Key"); cy.get(".ant-dropdown-menu-item")
.should("exist")
.should("not.contain", "Show API Key");
cy.getByTestId("QueryControlDropdownButton").click(); cy.getByTestId("QueryControlDropdownButton").click();
cy.get(".ant-dropdown-menu-item").should("exist"); cy.get(".ant-dropdown-menu-item").should("exist");
cy.getByTestId("ShowEmbedDialogButton").should("not.exist"); cy.getByTestId("ShowEmbedDialogButton").should("not.exist");
@@ -41,9 +42,8 @@ describe("Embedded Queries", () => {
}); });
it("can be shared without parameters", () => { it("can be shared without parameters", () => {
cy.createQuery({ query: "select name from users order by name" }).then((query) => { cy.createQuery({ query: "select name from users order by name" }).then(query => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -52,7 +52,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then((embedUrl) => { .then(embedUrl => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");
@@ -90,7 +90,7 @@ describe("Embedded Queries", () => {
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then((embedUrl) => { .then(embedUrl => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");

View File

@@ -44,7 +44,6 @@ describe("Box Plot", () => {
.then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {})) .then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {}))
.then(({ id: visualizationId, query_id: queryId }) => { .then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`); cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -62,7 +61,9 @@ describe("Box Plot", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg").should("exist"); cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] });
}); });

View File

@@ -31,7 +31,6 @@ describe("Chart", () => {
it("creates Bar charts", function () { it("creates Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction = const getBarChartAssertionFunction =
@@ -110,7 +109,6 @@ describe("Chart", () => {
}); });
it("colors Bar charts", function () { it("colors Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
@@ -125,7 +123,6 @@ describe("Chart", () => {
}); });
it("colors Pie charts", function () { it("colors Pie charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.GlobalSeriesType").click(); cy.getByTestId("Chart.GlobalSeriesType").click();

View File

@@ -34,7 +34,6 @@ describe("Choropleth", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -77,7 +76,9 @@ describe("Choropleth", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find(".map-visualization-container.leaflet-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".map-visualization-container.leaflet-container")
.should("exist");
cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] });
}); });

View File

@@ -24,7 +24,6 @@ describe("Cohort", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -52,7 +51,9 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist"); cy.getByTestId("VisualizationPreview")
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -63,7 +64,9 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist"); cy.getByTestId("VisualizationPreview")
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -12,7 +12,6 @@ describe("Counter", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -25,7 +24,9 @@ describe("Counter", () => {
Counter.General.ValueColumn.a Counter.General.ValueColumn.a
`); `);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -42,7 +43,9 @@ describe("Counter", () => {
"Counter.General.Label": "Custom Label", "Counter.General.Label": "Custom Label",
}); });
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -62,7 +65,9 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -78,7 +83,9 @@ describe("Counter", () => {
Counter.General.TargetValueColumn.b Counter.General.TargetValueColumn.b
`); `);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -99,7 +106,9 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -114,7 +123,9 @@ describe("Counter", () => {
Counter.General.CountRows Counter.General.CountRows
`); `);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -140,7 +151,9 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -167,7 +180,9 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist"); cy.getByTestId("VisualizationPreview")
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting

View File

@@ -5,25 +5,34 @@ describe("Edit visualization dialog", () => {
cy.login(); cy.login();
cy.createQuery().then(({ id }) => { cy.createQuery().then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
it("opens New Visualization dialog", () => { it("opens New Visualization dialog", () => {
cy.getByTestId("NewVisualization").should("exist").click(); cy.getByTestId("NewVisualization")
.should("exist")
.click();
cy.getByTestId("EditVisualizationDialog").should("exist"); cy.getByTestId("EditVisualizationDialog").should("exist");
// Default visualization should be selected // Default visualization should be selected
cy.getByTestId("VisualizationType").should("exist").should("contain", "Chart"); cy.getByTestId("VisualizationType")
cy.getByTestId("VisualizationName").should("exist").should("have.value", "Chart"); .should("exist")
.should("contain", "Chart");
cy.getByTestId("VisualizationName")
.should("exist")
.should("have.value", "Chart");
}); });
it("opens Edit Visualization dialog", () => { it("opens Edit Visualization dialog", () => {
cy.getByTestId("EditVisualization").click(); cy.getByTestId("EditVisualization").click();
cy.getByTestId("EditVisualizationDialog").should("exist"); cy.getByTestId("EditVisualizationDialog").should("exist");
// Default `Table` visualization should be selected // Default `Table` visualization should be selected
cy.getByTestId("VisualizationType").should("exist").should("contain", "Table"); cy.getByTestId("VisualizationType")
cy.getByTestId("VisualizationName").should("exist").should("have.value", "Table"); .should("exist")
.should("contain", "Table");
cy.getByTestId("VisualizationName")
.should("exist")
.should("have.value", "Table");
}); });
it("creates visualization with custom name", () => { it("creates visualization with custom name", () => {
@@ -35,9 +44,15 @@ describe("Edit visualization dialog", () => {
VisualizationType.TABLE VisualizationType.TABLE
`); `);
cy.getByTestId("VisualizationName").clear().type(visualizationName); cy.getByTestId("VisualizationName")
.clear()
.type(visualizationName);
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click(); cy.getByTestId("EditVisualizationDialog")
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist"); .contains("button", "Save")
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });

View File

@@ -25,7 +25,6 @@ describe("Funnel", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -60,7 +59,9 @@ describe("Funnel", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist"); cy.getByTestId("VisualizationPreview")
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -80,7 +81,9 @@ describe("Funnel", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist"); cy.getByTestId("VisualizationPreview")
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -24,7 +24,6 @@ describe("Map (Markers)", () => {
.then(({ id }) => cy.createVisualization(id, "MAP", "Map (Markers)", { mapTileUrl })) .then(({ id }) => cy.createVisualization(id, "MAP", "Map (Markers)", { mapTileUrl }))
.then(({ id: visualizationId, query_id: queryId }) => { .then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`); cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -52,7 +51,9 @@ describe("Map (Markers)", () => {
cy.fillInputs({ "ColorPicker.CustomColor": "blue{enter}" }); cy.fillInputs({ "ColorPicker.CustomColor": "blue{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible"); cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click(); cy.getByTestId("VisualizationPreview")
.find(".leaflet-control-zoom-in")
.click();
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -84,7 +85,9 @@ describe("Map (Markers)", () => {
cy.fillInputs({ "ColorPicker.CustomColor": "maroon{enter}" }); cy.fillInputs({ "ColorPicker.CustomColor": "maroon{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible"); cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click(); cy.getByTestId("VisualizationPreview")
.find(".leaflet-control-zoom-in")
.click();
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting

View File

@@ -19,7 +19,9 @@ const SQL = `
function createPivotThroughUI(visualizationName, options = {}) { function createPivotThroughUI(visualizationName, options = {}) {
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.PIVOT"); cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.PIVOT");
cy.getByTestId("VisualizationName").clear().type(visualizationName); cy.getByTestId("VisualizationName")
.clear()
.type(visualizationName);
if (options.hideControls) { if (options.hideControls) {
cy.getByTestId("PivotEditor.HideControls").click(); cy.getByTestId("PivotEditor.HideControls").click();
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview")
@@ -27,30 +29,36 @@ function createPivotThroughUI(visualizationName, options = {}) {
.find(".pvtAxisContainer, .pvtRenderer, .pvtVals") .find(".pvtAxisContainer, .pvtRenderer, .pvtVals")
.should("be.not.visible"); .should("be.not.visible");
} }
cy.getByTestId("VisualizationPreview").find("table").should("exist"); cy.getByTestId("VisualizationPreview")
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click(); .find("table")
.should("exist");
cy.getByTestId("EditVisualizationDialog")
.contains("button", "Save")
.click();
} }
describe("Pivot", () => { describe("Pivot", () => {
beforeEach(() => { beforeEach(() => {
cy.login(); cy.login();
cy.createQuery({ name: "Pivot Visualization", query: SQL }).its("id").as("queryId"); cy.createQuery({ name: "Pivot Visualization", query: SQL })
.its("id")
.as("queryId");
}); });
it("creates Pivot with controls", function() { it("creates Pivot with controls", function() {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot"; const visualizationName = "Pivot";
createPivotThroughUI(visualizationName); createPivotThroughUI(visualizationName);
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist"); cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
it("creates Pivot without controls", function() { it("creates Pivot without controls", function() {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot"; const visualizationName = "Pivot";
@@ -78,9 +86,8 @@ describe("Pivot", () => {
vals: ["value"], vals: ["value"],
}; };
cy.createVisualization(this.queryId, "PIVOT", "Pivot", options).then((visualization) => { cy.createVisualization(this.queryId, "PIVOT", "Pivot", options).then(visualization => {
cy.visit(`queries/${this.queryId}/source#${visualization.id}`); cy.visit(`queries/${this.queryId}/source#${visualization.id}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
// assert number of rows is 11 // assert number of rows is 11
@@ -97,7 +104,9 @@ describe("Pivot", () => {
cy.wait(200); cy.wait(200);
cy.getByTestId("SaveButton").click(); cy.getByTestId("SaveButton").click();
cy.getByTestId("ExecuteButton").should("be.enabled").click(); cy.getByTestId("ExecuteButton")
.should("be.enabled")
.click();
// assert number of rows is 12 // assert number of rows is 12
cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "12"); cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "12");
@@ -133,20 +142,19 @@ describe("Pivot", () => {
]; ];
cy.createDashboard("Pivot Visualization") cy.createDashboard("Pivot Visualization")
.then((dashboard) => { .then(dashboard => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy.all( return cy.all(
pivotTables.map( pivotTables.map(pivot => () =>
(pivot) => () =>
cy cy
.createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options) .createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options)
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position })) .then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position }))
) )
); );
}) })
.then((widgets) => { .then(widgets => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach((widget) => { widgets.forEach(widget => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.getByTestId(getWidgetTestId(widget)).within(() =>
cy.getByTestId("PivotTableVisualization").should("exist") cy.getByTestId("PivotTableVisualization").should("exist")
); );

View File

@@ -25,7 +25,6 @@ describe("Sankey and Sunburst", () => {
beforeEach(() => { beforeEach(() => {
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.SUNBURST_SEQUENCE"); cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.SUNBURST_SEQUENCE");
@@ -35,21 +34,37 @@ describe("Sankey and Sunburst", () => {
it("creates Sunburst", () => { it("creates Sunburst", () => {
const visualizationName = "Sunburst"; const visualizationName = "Sunburst";
cy.getByTestId("VisualizationName").clear().type(visualizationName); cy.getByTestId("VisualizationName")
cy.getByTestId("VisualizationPreview").find("svg").should("exist"); .clear()
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click(); cy.getByTestId("EditVisualizationDialog")
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist"); .contains("button", "Save")
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
it("creates Sankey", () => { it("creates Sankey", () => {
const visualizationName = "Sankey"; const visualizationName = "Sankey";
cy.getByTestId("VisualizationName").clear().type(visualizationName); cy.getByTestId("VisualizationName")
cy.getByTestId("VisualizationPreview").find("svg").should("exist"); .clear()
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click(); cy.getByTestId("EditVisualizationDialog")
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist"); .contains("button", "Save")
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });
@@ -78,21 +93,20 @@ describe("Sankey and Sunburst", () => {
]; ];
it("takes a snapshot with Sunburst (1 - 5 stages)", function() { it("takes a snapshot with Sunburst (1 - 5 stages)", function() {
cy.createDashboard("Sunburst Visualization").then((dashboard) => { cy.createDashboard("Sunburst Visualization").then(dashboard => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy return cy
.all( .all(
STAGES_WIDGETS.map( STAGES_WIDGETS.map(sunburst => () =>
(sunburst) => () =>
cy cy
.createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query }) .createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query })
.then((queryData) => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {})) .then(queryData => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position })) .then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position }))
) )
) )
.then((widgets) => { .then(widgets => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach((widget) => { widgets.forEach(widget => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist")); cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
}); });
@@ -104,21 +118,20 @@ describe("Sankey and Sunburst", () => {
}); });
it("takes a snapshot with Sankey (1 - 5 stages)", function() { it("takes a snapshot with Sankey (1 - 5 stages)", function() {
cy.createDashboard("Sankey Visualization").then((dashboard) => { cy.createDashboard("Sankey Visualization").then(dashboard => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy return cy
.all( .all(
STAGES_WIDGETS.map( STAGES_WIDGETS.map(sankey => () =>
(sankey) => () =>
cy cy
.createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query }) .createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query })
.then((queryData) => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {})) .then(queryData => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position })) .then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position }))
) )
) )
.then((widgets) => { .then(widgets => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach((widget) => { widgets.forEach(widget => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist")); cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
}); });

View File

@@ -64,7 +64,6 @@ describe("Word Cloud", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.document().then(injectFont); cy.document().then(injectFont);
@@ -81,7 +80,9 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 11); cy.getByTestId("VisualizationPreview")
.find("svg text")
.should("have.length", 11);
cy.percySnapshot("Visualizations - Word Cloud (Automatic word frequencies)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (Automatic word frequencies)", { widths: [viewportWidth] });
}); });
@@ -98,7 +99,9 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 5); cy.getByTestId("VisualizationPreview")
.find("svg text")
.should("have.length", 5);
cy.percySnapshot("Visualizations - Word Cloud (Frequencies from another column)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (Frequencies from another column)", { widths: [viewportWidth] });
}); });
@@ -122,7 +125,9 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 2); cy.getByTestId("VisualizationPreview")
.find("svg text")
.should("have.length", 2);
cy.percySnapshot("Visualizations - Word Cloud (With filters)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (With filters)", { widths: [viewportWidth] });
}); });

View File

@@ -53,7 +53,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:18-alpine image: pgautoupgrade/pgautoupgrade:latest
ports: ports:
- "15432:5432" - "15432:5432"
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3 # The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3

View File

@@ -1,26 +0,0 @@
"""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

View File

@@ -1,34 +0,0 @@
"""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);
""")

View File

@@ -1,6 +1,6 @@
{ {
"name": "redash-client", "name": "redash-client",
"version": "25.12.0-dev", "version": "25.03.0-dev",
"description": "The frontend part of Redash.", "description": "The frontend part of Redash.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -46,8 +46,8 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.2.1", "@ant-design/icons": "^4.2.1",
"@redash/viz": "file:viz-lib", "@redash/viz": "file:viz-lib",
"ace-builds": "^1.43.3", "ace-builds": "^1.4.12",
"antd": "4.4.3", "antd": "^4.4.3",
"axios": "0.27.2", "axios": "0.27.2",
"axios-auth-refresh": "3.3.6", "axios-auth-refresh": "3.3.6",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",
@@ -68,7 +68,7 @@
"prop-types": "^15.6.1", "prop-types": "^15.6.1",
"query-string": "^6.9.0", "query-string": "^6.9.0",
"react": "16.14.0", "react": "16.14.0",
"react-ace": "^14.0.1", "react-ace": "^9.1.1",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"react-grid-layout": "^0.18.2", "react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1", "react-resizable": "^1.10.1",
@@ -100,7 +100,6 @@
"@types/sql-formatter": "^2.3.0", "@types/sql-formatter": "^2.3.0",
"@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0", "@typescript-eslint/parser": "^2.10.0",
"assert": "^2.1.0",
"atob": "^2.1.2", "atob": "^2.1.2",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.1.0", "babel-jest": "^24.1.0",
@@ -139,24 +138,20 @@
"mini-css-extract-plugin": "^1.6.2", "mini-css-extract-plugin": "^1.6.2",
"mockdate": "^2.0.2", "mockdate": "^2.0.2",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "3.3.2", "prettier": "^1.19.1",
"process": "^0.11.10",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"request-cookies": "^1.1.0", "request-cookies": "^1.1.0",
"source-map-loader": "^1.1.3",
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"typescript": "4.1.2", "typescript": "^4.1.2",
"url": "^0.11.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.101.3", "webpack": "^4.46.0",
"webpack-build-notifier": "^3.0.1", "webpack-build-notifier": "^2.3.0",
"webpack-bundle-analyzer": "^4.9.0", "webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.15.1", "webpack-dev-server": "^4.15.1",
"webpack-manifest-plugin": "^5.0.1" "webpack-manifest-plugin": "^2.0.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "^2.3.2" "fsevents": "^2.3.2"

3135
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,5 @@
[project] [project]
name = "redash"
version = "25.12.0-dev"
requires-python = ">=3.8" requires-python = ">=3.8"
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
authors = [
{ name = "Arik Fraimovich", email = "<arik@redash.io>" }
]
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
maintainers = [
{ name = "Redash maintainers and contributors", email = "<maintainers@redash.io>" }
]
readme = "README.md"
dependencies = []
[tool.black] [tool.black]
target-version = ['py38'] target-version = ['py38']
@@ -22,6 +10,17 @@ force-exclude = '''
)/ )/
''' '''
[tool.poetry]
name = "redash"
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
maintainers = [
"Redash maintainers and contributors <maintainers@redash.io>",
]
readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.8,<3.11" python = ">=3.8,<3.11"
advocate = "1.0.0" advocate = "1.0.0"
@@ -90,14 +89,13 @@ pyodbc = "5.1.0"
debugpy = "^1.8.9" debugpy = "^1.8.9"
paramiko = "3.4.1" paramiko = "3.4.1"
oracledb = "2.5.1" oracledb = "2.5.1"
ibm-db = { version = "^3.2.7", markers = "platform_machine == 'x86_64' or platform_machine == 'AMD64'" }
[tool.poetry.group.all_ds] [tool.poetry.group.all_ds]
optional = true optional = true
[tool.poetry.group.all_ds.dependencies] [tool.poetry.group.all_ds.dependencies]
atsd-client = "3.0.5" atsd-client = "3.0.5"
azure-kusto-data = "5.0.1" azure-kusto-data = "0.0.35"
boto3 = "1.28.8" boto3 = "1.28.8"
botocore = "1.31.8" botocore = "1.31.8"
cassandra-driver = "3.21.0" cassandra-driver = "3.21.0"
@@ -105,7 +103,6 @@ certifi = ">=2019.9.11"
cmem-cmempy = "21.2.3" cmem-cmempy = "21.2.3"
databend-py = "0.4.6" databend-py = "0.4.6"
databend-sqlalchemy = "0.2.4" databend-sqlalchemy = "0.2.4"
duckdb = "1.3.2"
google-api-python-client = "1.7.11" google-api-python-client = "1.7.11"
gspread = "5.11.2" gspread = "5.11.2"
impyla = "0.16.0" impyla = "0.16.0"
@@ -113,7 +110,6 @@ influxdb = "5.2.3"
influxdb-client = "1.38.0" influxdb-client = "1.38.0"
memsql = "3.2.0" memsql = "3.2.0"
mysqlclient = "2.1.1" mysqlclient = "2.1.1"
numpy = "1.24.4"
nzalchemy = "^11.0.2" nzalchemy = "^11.0.2"
nzpy = ">=1.15" nzpy = ">=1.15"
oauth2client = "4.1.3" oauth2client = "4.1.3"

View File

@@ -14,7 +14,7 @@ from redash.app import create_app # noqa
from redash.destinations import import_destinations from redash.destinations import import_destinations
from redash.query_runner import import_query_runners from redash.query_runner import import_query_runners
__version__ = "25.12.0-dev" __version__ = "25.03.0-dev"
if os.environ.get("REMOTE_DEBUG"): if os.environ.get("REMOTE_DEBUG"):

View File

@@ -4,7 +4,7 @@ import requests
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
from flask import Blueprint, flash, redirect, request, session, url_for from flask import Blueprint, flash, redirect, request, session, url_for
from redash import models, settings from redash import models
from redash.authentication import ( from redash.authentication import (
create_and_login_user, create_and_login_user,
get_next_path, get_next_path,
@@ -29,8 +29,22 @@ def verify_profile(org, profile):
return False return False
def get_user_profile(access_token, logger): def create_google_oauth_blueprint(app):
headers = {"Authorization": f"OAuth {access_token}"} oauth = OAuth(app)
logger = logging.getLogger("google_oauth")
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) response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
if response.status_code == 401: if response.status_code == 401:
@@ -39,44 +53,6 @@ def get_user_profile(access_token, logger):
return response.json() 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)
logger = logging.getLogger("google_oauth")
blueprint = Blueprint("google_oauth", __name__)
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
oauth.register(
name="google",
server_metadata_url=CONF_URL,
client_kwargs={"scope": "openid email profile"},
)
@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org") @blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
def org_login(org_slug): def org_login(org_slug):
session["org_slug"] = current_org.slug session["org_slug"] = current_org.slug
@@ -84,9 +60,9 @@ def create_google_oauth_blueprint(app):
@blueprint.route("/oauth/google", endpoint="authorize") @blueprint.route("/oauth/google", endpoint="authorize")
def login(): def login():
redirect_uri = build_redirect_uri() redirect_uri = url_for(".callback", _external=True)
next_path = build_next_path() next_path = request.args.get("next", url_for("redash.index", org_slug=session.get("org_slug")))
logger.debug("Callback url: %s", redirect_uri) logger.debug("Callback url: %s", redirect_uri)
logger.debug("Next is: %s", next_path) logger.debug("Next is: %s", next_path)
@@ -110,7 +86,7 @@ def create_google_oauth_blueprint(app):
flash("Validation error. Please retry.") flash("Validation error. Please retry.")
return redirect(url_for("redash.login")) return redirect(url_for("redash.login"))
profile = get_user_profile(access_token, logger) profile = get_user_profile(access_token)
if profile is None: if profile is None:
flash("Validation error. Please retry.") flash("Validation error. Please retry.")
return redirect(url_for("redash.login")) return redirect(url_for("redash.login"))
@@ -134,9 +110,7 @@ def create_google_oauth_blueprint(app):
if user is None: if user is None:
return logout_and_redirect_to_index() return logout_and_redirect_to_index()
unsafe_next_path = session.get("next_url") unsafe_next_path = session.get("next_url") or url_for("redash.index", org_slug=org.slug)
if not unsafe_next_path:
unsafe_next_path = build_next_path(org.slug)
next_path = get_next_path(unsafe_next_path) next_path = get_next_path(unsafe_next_path)
return redirect(next_path) return redirect(next_path)

View File

@@ -42,7 +42,7 @@ class Webhook(BaseDestination):
auth = HTTPBasicAuth(options.get("username"), options.get("password")) if options.get("username") else None auth = HTTPBasicAuth(options.get("username"), options.get("password")) if options.get("username") else None
resp = requests.post( resp = requests.post(
options.get("url"), options.get("url"),
data=json_dumps(data).encode("utf-8"), data=json_dumps(data),
auth=auth, auth=auth,
headers=headers, headers=headers,
timeout=5.0, timeout=5.0,

View File

@@ -255,12 +255,6 @@ def number_format_config():
} }
def null_value_config():
return {
"nullValue": current_org.get_setting("null_value"),
}
def client_config(): def client_config():
if not current_user.is_api_user() and current_user.is_authenticated: if not current_user.is_api_user() and current_user.is_authenticated:
client_config = { client_config = {
@@ -278,7 +272,6 @@ def client_config():
"showPermissionsControl": current_org.get_setting("feature_show_permissions_control"), "showPermissionsControl": current_org.get_setting("feature_show_permissions_control"),
"hidePlotlyModeBar": current_org.get_setting("hide_plotly_mode_bar"), "hidePlotlyModeBar": current_org.get_setting("hide_plotly_mode_bar"),
"disablePublicUrls": current_org.get_setting("disable_public_urls"), "disablePublicUrls": current_org.get_setting("disable_public_urls"),
"multiByteSearchEnabled": current_org.get_setting("multi_byte_search_enabled"),
"allowCustomJSVisualizations": settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS, "allowCustomJSVisualizations": settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS,
"autoPublishNamedQueries": settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES, "autoPublishNamedQueries": settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES,
"extendedAlertOptions": settings.FEATURE_EXTENDED_ALERT_OPTIONS, "extendedAlertOptions": settings.FEATURE_EXTENDED_ALERT_OPTIONS,
@@ -296,7 +289,6 @@ def client_config():
client_config.update({"basePath": base_href()}) client_config.update({"basePath": base_href()})
client_config.update(date_time_format_config()) client_config.update(date_time_format_config())
client_config.update(number_format_config()) client_config.update(number_format_config())
client_config.update(null_value_config())
return client_config return client_config

View File

@@ -26,8 +26,6 @@ order_map = {
"-name": "-lowercase_name", "-name": "-lowercase_name",
"created_at": "created_at", "created_at": "created_at",
"-created_at": "-created_at", "-created_at": "-created_at",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
} }
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map) order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)

View File

@@ -44,8 +44,6 @@ order_map = {
"-executed_at": "-query_results-retrieved_at", "-executed_at": "-query_results-retrieved_at",
"created_by": "users-name", "created_by": "users-name",
"-created_by": "-users-name", "-created_by": "-users-name",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
} }
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map) order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)
@@ -241,8 +239,6 @@ class QueryListResource(BaseQueryListResource):
query = models.Query.create(**query_def) query = models.Query.create(**query_def)
models.db.session.add(query) models.db.session.add(query)
models.db.session.commit() models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
self.record_event({"action": "create", "object_id": query.id, "object_type": "query"}) self.record_event({"action": "create", "object_id": query.id, "object_type": "query"})
@@ -366,8 +362,6 @@ class QueryResource(BaseResource):
try: try:
self.update_model(query, query_def) self.update_model(query, query_def)
models.db.session.commit() models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
except StaleDataError: except StaleDataError:
abort(409) abort(409)

View File

@@ -2,7 +2,6 @@ import calendar
import datetime import datetime
import logging import logging
import numbers import numbers
import re
import time import time
import pytz import pytz
@@ -229,7 +228,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
def _sort_schema(self, schema): def _sort_schema(self, schema):
return [ return [
{**i, "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)} {"name": i["name"], "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)}
for i in sorted(schema, key=lambda x: x["name"]) for i in sorted(schema, key=lambda x: x["name"])
] ]
@@ -565,7 +564,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
db.session.query(tag_column, usage_count) db.session.query(tag_column, usage_count)
.group_by(tag_column) .group_by(tag_column)
.filter(Query.id.in_(queries.options(load_only("id")))) .filter(Query.id.in_(queries.options(load_only("id"))))
.order_by(tag_column) .order_by(usage_count.desc())
) )
return query return query
@@ -645,43 +644,6 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return list(outdated_queries.values()) return list(outdated_queries.values())
@classmethod
def _do_multi_byte_search(cls, all_queries, term, limit=None):
# term examples:
# - word
# - name:word
# - query:word
# - "multiple words"
# - name:"multiple words"
# - word1 word2 word3
# - word1 "multiple word" query:"select foo"
tokens = re.findall(r'(?:([^:\s]+):)?(?:"([^"]+)"|(\S+))', term)
conditions = []
for token in tokens:
key = None
if token[0]:
key = token[0]
if token[1]:
value = token[1]
else:
value = token[2]
pattern = f"%{value}%"
if key == "id" and value.isdigit():
conditions.append(cls.id.equal(int(value)))
elif key == "name":
conditions.append(cls.name.ilike(pattern))
elif key == "query":
conditions.append(cls.query_text.ilike(pattern))
elif key == "description":
conditions.append(cls.description.ilike(pattern))
else:
conditions.append(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
return all_queries.filter(and_(*conditions)).order_by(Query.id).limit(limit)
@classmethod @classmethod
def search( def search(
cls, cls,
@@ -702,7 +664,12 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
if multi_byte_search: if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too # Since tsvector doesn't work well with CJK languages, use `ilike` too
return cls._do_multi_byte_search(all_queries, term, limit) pattern = "%{}%".format(term)
return (
all_queries.filter(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
.order_by(Query.id)
.limit(limit)
)
# sort the result using the weight as defined in the search vector column # sort the result using the weight as defined in the search vector column
return all_queries.search(term, sort=True).limit(limit) return all_queries.search(term, sort=True).limit(limit)
@@ -711,7 +678,13 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def search_by_user(cls, term, user, limit=None, multi_byte_search=False): def search_by_user(cls, term, user, limit=None, multi_byte_search=False):
if multi_byte_search: if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too # Since tsvector doesn't work well with CJK languages, use `ilike` too
return cls._do_multi_byte_search(cls.by_user(user), term, limit) pattern = "%{}%".format(term)
return (
cls.by_user(user)
.filter(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
.order_by(Query.id)
.limit(limit)
)
return cls.by_user(user).search(term, sort=True).limit(limit) return cls.by_user(user).search(term, sort=True).limit(limit)
@@ -753,23 +726,6 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return db.session.execute(query, {"ids": tuple(query_ids)}).fetchall() return db.session.execute(query, {"ids": tuple(query_ids)}).fetchall()
def update_latest_result_by_query_hash(self):
query_hash = self.query_hash
data_source_id = self.data_source_id
query_result = (
QueryResult.query.options(load_only("id"))
.filter(
QueryResult.query_hash == query_hash,
QueryResult.data_source_id == data_source_id,
)
.order_by(QueryResult.retrieved_at.desc())
.first()
)
if query_result:
latest_query_data_id = query_result.id
self.latest_query_data_id = latest_query_data_id
db.session.add(self)
@classmethod @classmethod
def update_latest_result(cls, query_result): def update_latest_result(cls, query_result):
# TODO: Investigate how big an impact this select-before-update makes. # TODO: Investigate how big an impact this select-before-update makes.
@@ -1181,7 +1137,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
db.session.query(tag_column, usage_count) db.session.query(tag_column, usage_count)
.group_by(tag_column) .group_by(tag_column)
.filter(Dashboard.id.in_(dashboards.options(load_only("id")))) .filter(Dashboard.id.in_(dashboards.options(load_only("id"))))
.order_by(tag_column) .order_by(usage_count.desc())
) )
return query return query
@@ -1189,9 +1145,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
def favorites(cls, user, base_query=None): def favorites(cls, user, base_query=None):
if base_query is None: if base_query is None:
base_query = cls.all(user.org, user.group_ids, user.id) base_query = cls.all(user.org, user.group_ids, user.id)
return ( return base_query.join(
base_query.distinct(cls.lowercase_name, Dashboard.created_at, Dashboard.slug, Favorite.created_at)
.join(
( (
Favorite, Favorite,
and_( and_(
@@ -1199,9 +1153,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
Favorite.object_id == Dashboard.id, Favorite.object_id == Dashboard.id,
), ),
) )
) ).filter(Favorite.user_id == user.id)
.filter(Favorite.user_id == user.id)
)
@classmethod @classmethod
def by_user(cls, user): def by_user(cls, user):

View File

@@ -288,10 +288,7 @@ class BaseSQLQueryRunner(BaseQueryRunner):
return True return True
def query_is_select_no_limit(self, query): def query_is_select_no_limit(self, query):
parsed_query_list = sqlparse.parse(query) parsed_query = sqlparse.parse(query)[0]
if len(parsed_query_list) == 0:
return False
parsed_query = parsed_query_list[0]
last_keyword_idx = find_last_keyword_idx(parsed_query) last_keyword_idx = find_last_keyword_idx(parsed_query)
# Either invalid query or query that is not select # Either invalid query or query that is not select
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT": if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":

View File

@@ -11,12 +11,12 @@ from redash.query_runner import (
from redash.utils import json_loads from redash.utils import json_loads
try: try:
from azure.kusto.data import ( from azure.kusto.data.exceptions import KustoServiceError
from azure.kusto.data.request import (
ClientRequestProperties, ClientRequestProperties,
KustoClient, KustoClient,
KustoConnectionStringBuilder, KustoConnectionStringBuilder,
) )
from azure.kusto.data.exceptions import KustoServiceError
enabled = True enabled = True
except ImportError: except ImportError:
@@ -37,34 +37,6 @@ TYPES_MAP = {
} }
def _get_data_scanned(kusto_response):
try:
metadata_table = next(
(table for table in kusto_response.tables if table.table_name == "QueryCompletionInformation"),
None,
)
if metadata_table:
resource_usage_json = next(
(row["Payload"] for row in metadata_table.rows if row["EventTypeName"] == "QueryResourceConsumption"),
"{}",
)
resource_usage = json_loads(resource_usage_json).get("resource_usage", {})
data_scanned = (
resource_usage["cache"]["shards"]["cold"]["hitbytes"]
+ resource_usage["cache"]["shards"]["cold"]["missbytes"]
+ resource_usage["cache"]["shards"]["hot"]["hitbytes"]
+ resource_usage["cache"]["shards"]["hot"]["missbytes"]
+ resource_usage["cache"]["shards"]["bypassbytes"]
)
except Exception:
data_scanned = 0
return int(data_scanned)
class AzureKusto(BaseQueryRunner): class AzureKusto(BaseQueryRunner):
should_annotate_query = False should_annotate_query = False
noop_query = "let noop = datatable (Noop:string)[1]; noop" noop_query = "let noop = datatable (Noop:string)[1]; noop"
@@ -72,6 +44,8 @@ class AzureKusto(BaseQueryRunner):
def __init__(self, configuration): def __init__(self, configuration):
super(AzureKusto, self).__init__(configuration) super(AzureKusto, self).__init__(configuration)
self.syntax = "custom" self.syntax = "custom"
self.client_request_properties = ClientRequestProperties()
self.client_request_properties.application = "redash"
@classmethod @classmethod
def configuration_schema(cls): def configuration_schema(cls):
@@ -86,14 +60,12 @@ class AzureKusto(BaseQueryRunner):
}, },
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"}, "azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
"database": {"type": "string"}, "database": {"type": "string"},
"msi": {"type": "boolean", "title": "Use Managed Service Identity"},
"user_msi": {
"type": "string",
"title": "User-assigned managed identity client ID",
},
}, },
"required": [ "required": [
"cluster", "cluster",
"azure_ad_client_id",
"azure_ad_client_secret",
"azure_ad_tenant_id",
"database", "database",
], ],
"order": [ "order": [
@@ -119,48 +91,18 @@ class AzureKusto(BaseQueryRunner):
return "Azure Data Explorer (Kusto)" return "Azure Data Explorer (Kusto)"
def run_query(self, query, user): def run_query(self, query, user):
cluster = self.configuration["cluster"]
msi = self.configuration.get("msi", False)
# Managed Service Identity(MSI)
if msi:
# If user-assigned managed identity is used, the client ID must be provided
if self.configuration.get("user_msi"):
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(
cluster,
client_id=self.configuration["user_msi"],
)
else:
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(cluster)
# Service Principal auth
else:
aad_app_id = self.configuration.get("azure_ad_client_id")
app_key = self.configuration.get("azure_ad_client_secret")
authority_id = self.configuration.get("azure_ad_tenant_id")
if not (aad_app_id and app_key and authority_id):
raise ValueError(
"Azure AD Client ID, Client Secret, and Tenant ID are required for Service Principal authentication."
)
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication( kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
connection_string=cluster, connection_string=self.configuration["cluster"],
aad_app_id=aad_app_id, aad_app_id=self.configuration["azure_ad_client_id"],
app_key=app_key, app_key=self.configuration["azure_ad_client_secret"],
authority_id=authority_id, authority_id=self.configuration["azure_ad_tenant_id"],
) )
client = KustoClient(kcsb) client = KustoClient(kcsb)
request_properties = ClientRequestProperties()
request_properties.application = "redash"
if user:
request_properties.user = user.email
request_properties.set_option("request_description", user.email)
db = self.configuration["database"] db = self.configuration["database"]
try: try:
response = client.execute(db, query, request_properties) response = client.execute(db, query, self.client_request_properties)
result_cols = response.primary_results[0].columns result_cols = response.primary_results[0].columns
result_rows = response.primary_results[0].rows result_rows = response.primary_results[0].rows
@@ -181,15 +123,14 @@ class AzureKusto(BaseQueryRunner):
rows.append(row.to_dict()) rows.append(row.to_dict())
error = None error = None
data = { data = {"columns": columns, "rows": rows}
"columns": columns,
"rows": rows,
"metadata": {"data_scanned": _get_data_scanned(response)},
}
except KustoServiceError as err: except KustoServiceError as err:
data = None data = None
error = str(err) try:
error = err.args[1][0]["error"]["@message"]
except (IndexError, KeyError):
error = err.args[1]
return data, error return data, error
@@ -202,10 +143,7 @@ class AzureKusto(BaseQueryRunner):
self._handle_run_query_error(error) self._handle_run_query_error(error)
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"]) schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
tables_list = [ tables_list = schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()
*(schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()),
*(schema_as_json["Databases"][self.configuration["database"]]["MaterializedViews"].values()),
]
schema = {} schema = {}
@@ -216,9 +154,7 @@ class AzureKusto(BaseQueryRunner):
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
for column in table["OrderedColumns"]: for column in table["OrderedColumns"]:
schema[table_name]["columns"].append( schema[table_name]["columns"].append(column["Name"])
{"name": column["Name"], "type": TYPES_MAP.get(column["CslType"], None)}
)
return list(schema.values()) return list(schema.values())

View File

@@ -12,7 +12,7 @@ from redash.query_runner import (
TYPE_FLOAT, TYPE_FLOAT,
TYPE_INTEGER, TYPE_INTEGER,
TYPE_STRING, TYPE_STRING,
BaseSQLQueryRunner, BaseQueryRunner,
InterruptException, InterruptException,
JobTimeoutException, JobTimeoutException,
register, register,
@@ -86,7 +86,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index):
).execute() ).execute()
logging.debug("query_reply %s", query_reply) logging.debug("query_reply %s", query_reply)
if not query_reply["jobComplete"]: if not query_reply["jobComplete"]:
time.sleep(1) time.sleep(10)
return _get_query_results(jobs, project_id, location, job_id, start_index) return _get_query_results(jobs, project_id, location, job_id, start_index)
return query_reply return query_reply
@@ -98,7 +98,7 @@ def _get_total_bytes_processed_for_resp(bq_response):
return int(bq_response.get("totalBytesProcessed", "0")) return int(bq_response.get("totalBytesProcessed", "0"))
class BigQuery(BaseSQLQueryRunner): class BigQuery(BaseQueryRunner):
noop_query = "SELECT 1" noop_query = "SELECT 1"
def __init__(self, configuration): def __init__(self, configuration):
@@ -156,11 +156,6 @@ class BigQuery(BaseSQLQueryRunner):
"secret": ["jsonKeyFile"], "secret": ["jsonKeyFile"],
} }
def annotate_query(self, query, metadata):
# Remove "Job ID" before annotating the query to avoid cache misses
metadata = {k: v for k, v in metadata.items() if k != "Job ID"}
return super().annotate_query(query, metadata)
def _get_bigquery_service(self): def _get_bigquery_service(self):
socket.setdefaulttimeout(settings.BIGQUERY_HTTP_TIMEOUT) socket.setdefaulttimeout(settings.BIGQUERY_HTTP_TIMEOUT)
@@ -220,12 +215,11 @@ class BigQuery(BaseSQLQueryRunner):
job_data = self._get_job_data(query) job_data = self._get_job_data(query)
insert_response = jobs.insert(projectId=project_id, body=job_data).execute() insert_response = jobs.insert(projectId=project_id, body=job_data).execute()
self.current_job_id = insert_response["jobReference"]["jobId"] self.current_job_id = insert_response["jobReference"]["jobId"]
self.current_job_location = insert_response["jobReference"]["location"]
current_row = 0 current_row = 0
query_reply = _get_query_results( query_reply = _get_query_results(
jobs, jobs,
project_id=project_id, project_id=project_id,
location=self.current_job_location, location=self._get_location(),
job_id=self.current_job_id, job_id=self.current_job_id,
start_index=current_row, start_index=current_row,
) )
@@ -242,11 +236,13 @@ class BigQuery(BaseSQLQueryRunner):
query_result_request = { query_result_request = {
"projectId": project_id, "projectId": project_id,
"jobId": self.current_job_id, "jobId": query_reply["jobReference"]["jobId"],
"startIndex": current_row, "startIndex": current_row,
"location": self.current_job_location,
} }
if self._get_location():
query_result_request["location"] = self._get_location()
query_reply = jobs.getQueryResults(**query_result_request).execute() query_reply = jobs.getQueryResults(**query_result_request).execute()
columns = [ columns = [
@@ -308,34 +304,15 @@ class BigQuery(BaseSQLQueryRunner):
datasets = self._get_project_datasets(project_id) datasets = self._get_project_datasets(project_id)
query_base = """ query_base = """
SELECT table_schema, table_name, field_path, data_type, description SELECT table_schema, table_name, field_path, data_type
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
WHERE table_schema NOT IN ('information_schema') WHERE table_schema NOT IN ('information_schema')
""" """
table_query_base = """
SELECT table_schema, table_name, JSON_VALUE(option_value) as table_description
FROM `{dataset_id}`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE table_schema NOT IN ('information_schema')
AND option_name = 'description'
"""
location_dataset_ids = {}
schema = {} schema = {}
queries = []
for dataset in datasets: for dataset in datasets:
dataset_id = dataset["datasetReference"]["datasetId"] 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
if location not in location_dataset_ids:
location_dataset_ids[location] = []
location_dataset_ids[location].append(dataset_id)
for location, datasets in location_dataset_ids.items():
queries = []
for dataset_id in datasets:
query = query_base.format(dataset_id=dataset_id) query = query_base.format(dataset_id=dataset_id)
queries.append(query) queries.append(query)
@@ -348,30 +325,7 @@ class BigQuery(BaseSQLQueryRunner):
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"]) table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema: if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append( schema[table_name]["columns"].append({"name": row["field_path"], "type": row["data_type"]})
{
"name": row["field_path"],
"type": row["data_type"],
"description": row["description"],
}
)
table_queries = []
for dataset_id in datasets:
table_query = table_query_base.format(dataset_id=dataset_id)
table_queries.append(table_query)
table_query = "\nUNION ALL\n".join(table_queries)
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_description" in row:
schema[table_name]["description"] = row["table_description"]
return list(schema.values()) return list(schema.values())
@@ -405,7 +359,7 @@ class BigQuery(BaseSQLQueryRunner):
self._get_bigquery_service().jobs().cancel( self._get_bigquery_service().jobs().cancel(
projectId=self._get_project_id(), projectId=self._get_project_id(),
jobId=self.current_job_id, jobId=self.current_job_id,
location=self.current_job_location, location=self._get_location(),
).execute() ).execute()
raise raise

View File

@@ -77,11 +77,7 @@ class ClickHouse(BaseSQLQueryRunner):
self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port)) self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port))
def _get_tables(self, schema): def _get_tables(self, schema):
query = """ query = "SELECT database, table, name FROM system.columns WHERE database NOT IN ('system')"
SELECT database, table, name, type as data_type
FROM system.columns
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
"""
results, error = self.run_query(query, None) results, error = self.run_query(query, None)
@@ -94,7 +90,7 @@ class ClickHouse(BaseSQLQueryRunner):
if table_name not in schema: if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append({"name": row["name"], "type": row["data_type"]}) schema[table_name]["columns"].append(row["name"])
return list(schema.values()) return list(schema.values())

View File

@@ -1,174 +0,0 @@
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
register,
)
logger = logging.getLogger(__name__)
try:
import duckdb
enabled = True
except ImportError:
enabled = False
# Map DuckDB types to Redash column types
TYPES_MAP = {
"BOOLEAN": TYPE_BOOLEAN,
"TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"INTEGER": TYPE_INTEGER,
"BIGINT": TYPE_INTEGER,
"HUGEINT": TYPE_INTEGER,
"REAL": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
"VARCHAR": TYPE_STRING,
"BLOB": TYPE_STRING,
"DATE": TYPE_DATE,
"TIMESTAMP": TYPE_DATETIME,
"TIMESTAMP WITH TIME ZONE": TYPE_DATETIME,
"TIME": TYPE_DATETIME,
"INTERVAL": TYPE_STRING,
"UUID": TYPE_STRING,
"JSON": TYPE_STRING,
"STRUCT": TYPE_STRING,
"MAP": TYPE_STRING,
"UNION": TYPE_STRING,
}
class DuckDB(BaseSQLQueryRunner):
noop_query = "SELECT 1"
def __init__(self, configuration):
super().__init__(configuration)
self.dbpath = configuration.get("dbpath", ":memory:")
exts = configuration.get("extensions", "")
self.extensions = [e.strip() for e in exts.split(",") if e.strip()]
self._connect()
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"dbpath": {
"type": "string",
"title": "Database Path",
"default": ":memory:",
},
"extensions": {"type": "string", "title": "Extensions (comma separated)"},
},
"order": ["dbpath", "extensions"],
"required": ["dbpath"],
}
@classmethod
def enabled(cls) -> bool:
return enabled
def _connect(self) -> None:
self.con = duckdb.connect(self.dbpath)
for ext in self.extensions:
try:
if "." in ext:
prefix, name = ext.split(".", 1)
if prefix == "community":
self.con.execute(f"INSTALL {name} FROM community")
self.con.execute(f"LOAD {name}")
else:
raise Exception("Unknown extension prefix.")
else:
self.con.execute(f"INSTALL {ext}")
self.con.execute(f"LOAD {ext}")
except Exception as e:
logger.warning("Failed to load extension %s: %s", ext, e)
def run_query(self, query, user) -> tuple:
try:
cursor = self.con.cursor()
cursor.execute(query)
columns = self.fetch_columns(
[(d[0], TYPES_MAP.get(d[1].upper(), TYPE_STRING)) for d in cursor.description]
)
rows = [dict(zip((col["name"] for col in columns), row)) for row in cursor.fetchall()]
data = {"columns": columns, "rows": rows}
return data, None
except duckdb.InterruptException:
raise InterruptException("Query cancelled by user.")
except Exception as e:
logger.exception("Error running query: %s", e)
return None, str(e)
def get_schema(self, get_stats=False) -> list:
tables_query = """
SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema', 'pg_catalog');
"""
tables_results, error = self.run_query(tables_query, None)
if error:
raise Exception(f"Failed to get tables: {error}")
schema = {}
for table_row in tables_results["rows"]:
full_table_name = f"{table_row['table_schema']}.{table_row['table_name']}"
schema[full_table_name] = {"name": full_table_name, "columns": []}
describe_query = f'DESCRIBE "{table_row["table_schema"]}"."{table_row["table_name"]}";'
columns_results, error = self.run_query(describe_query, None)
if error:
logger.warning("Failed to describe table %s: %s", full_table_name, error)
continue
for col_row in columns_results["rows"]:
col = {"name": col_row["column_name"], "type": col_row["column_type"]}
schema[full_table_name]["columns"].append(col)
if col_row["column_type"].startswith("STRUCT("):
schema[full_table_name]["columns"].extend(
self._expand_struct_fields(col["name"], col_row["column_type"])
)
return list(schema.values())
def _expand_struct_fields(self, base_name: str, struct_type: str) -> list:
"""Recursively expand STRUCT(...) definitions into pseudo-columns."""
fields = []
# strip STRUCT( ... )
inner = struct_type[len("STRUCT(") : -1].strip()
# careful: nested structs, so parse comma-separated parts properly
depth, current, parts = 0, [], []
for c in inner:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if c == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(c)
if current:
parts.append("".join(current).strip())
for part in parts:
# each part looks like: "fieldname TYPE"
fname, ftype = part.split(" ", 1)
colname = f"{base_name}.{fname}"
fields.append({"name": colname, "type": ftype})
if ftype.startswith("STRUCT("):
fields.extend(self._expand_struct_fields(colname, ftype))
return fields
register(DuckDB)

View File

@@ -34,13 +34,9 @@ class ResultSet:
def parse_issue(issue, field_mapping): # noqa: C901 def parse_issue(issue, field_mapping): # noqa: C901
result = OrderedDict() result = OrderedDict()
result["key"] = issue["key"]
# Handle API v3 response format: key field may be missing, use id as fallback for k, v in issue["fields"].items(): #
result["key"] = issue.get("key", issue.get("id", "unknown"))
# Handle API v3 response format: fields may be missing
fields = issue.get("fields", {})
for k, v in fields.items(): #
output_name = field_mapping.get_output_field_name(k) output_name = field_mapping.get_output_field_name(k)
member_names = field_mapping.get_dict_members(k) member_names = field_mapping.get_dict_members(k)
@@ -102,9 +98,7 @@ def parse_issues(data, field_mapping):
def parse_count(data): def parse_count(data):
results = ResultSet() results = ResultSet()
# API v3 may not return 'total' field, fallback to counting issues results.add_row({"count": data["total"]})
count = data.get("total", len(data.get("issues", [])))
results.add_row({"count": count})
return results return results
@@ -166,26 +160,18 @@ class JiraJQL(BaseHTTPQueryRunner):
self.syntax = "json" self.syntax = "json"
def run_query(self, query, user): def run_query(self, query, user):
# Updated to API v3 endpoint, fix double slash issue jql_url = "{}/rest/api/2/search".format(self.configuration["url"])
jql_url = "{}/rest/api/3/search/jql".format(self.configuration["url"].rstrip("/"))
query = json_loads(query) query = json_loads(query)
query_type = query.pop("queryType", "select") query_type = query.pop("queryType", "select")
field_mapping = FieldMapping(query.pop("fieldMapping", {})) field_mapping = FieldMapping(query.pop("fieldMapping", {}))
# API v3 requires mandatory jql parameter with restrictions
if "jql" not in query or not query["jql"]:
query["jql"] = "created >= -30d order by created DESC"
if query_type == "count": if query_type == "count":
query["maxResults"] = 1 query["maxResults"] = 1
query["fields"] = "" query["fields"] = ""
else: else:
query["maxResults"] = query.get("maxResults", 1000) query["maxResults"] = query.get("maxResults", 1000)
if "fields" not in query:
query["fields"] = "*all"
response, error = self.get_response(jql_url, params=query) response, error = self.get_response(jql_url, params=query)
if error is not None: if error is not None:
return None, error return None, error
@@ -196,15 +182,17 @@ class JiraJQL(BaseHTTPQueryRunner):
results = parse_count(data) results = parse_count(data)
else: else:
results = parse_issues(data, field_mapping) results = parse_issues(data, field_mapping)
index = data["startAt"] + data["maxResults"]
# API v3 uses token-based pagination instead of startAt/total while data["total"] > index:
while not data.get("isLast", True) and "nextPageToken" in data: query["startAt"] = index
query["nextPageToken"] = data["nextPageToken"]
response, error = self.get_response(jql_url, params=query) response, error = self.get_response(jql_url, params=query)
if error is not None: if error is not None:
return None, error return None, error
data = response.json() data = response.json()
index = data["startAt"] + data["maxResults"]
addl_results = parse_issues(data, field_mapping) addl_results = parse_issues(data, field_mapping)
results.merge(addl_results) results.merge(addl_results)

View File

@@ -215,10 +215,10 @@ class MongoDB(BaseQueryRunner):
if readPreference: if readPreference:
kwargs["readPreference"] = readPreference kwargs["readPreference"] = readPreference
if self.configuration.get("username"): if "username" in self.configuration:
kwargs["username"] = self.configuration["username"] kwargs["username"] = self.configuration["username"]
if self.configuration.get("password"): if "password" in self.configuration:
kwargs["password"] = self.configuration["password"] kwargs["password"] = self.configuration["password"]
db_connection = pymongo.MongoClient(self.configuration["connectionString"], **kwargs) db_connection = pymongo.MongoClient(self.configuration["connectionString"], **kwargs)

View File

@@ -150,9 +150,7 @@ class Mysql(BaseSQLQueryRunner):
query = """ query = """
SELECT col.table_schema as table_schema, SELECT col.table_schema as table_schema,
col.table_name as table_name, col.table_name as table_name,
col.column_name as column_name, col.column_name as column_name
col.data_type as data_type,
col.column_comment as column_comment
FROM `information_schema`.`columns` col FROM `information_schema`.`columns` col
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys'); WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys');
""" """
@@ -171,38 +169,7 @@ class Mysql(BaseSQLQueryRunner):
if table_name not in schema: if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append( schema[table_name]["columns"].append(row["column_name"])
{
"name": row["column_name"],
"type": row["data_type"],
"description": row["column_comment"],
}
)
table_query = """
SELECT col.table_schema as table_schema,
col.table_name as table_name,
col.table_comment as table_comment
FROM `information_schema`.`tables` col
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys'); \
"""
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_comment" in row and row["table_comment"]:
schema[table_name]["description"] = row["table_comment"]
return list(schema.values()) return list(schema.values())

View File

@@ -138,15 +138,6 @@ def _get_ssl_config(configuration):
return ssl_config return ssl_config
def _parse_dsn(configuration):
standard_params = {"user", "password", "host", "port", "dbname"}
params = psycopg2.extensions.parse_dsn(configuration.get("dsn", ""))
overlap = standard_params.intersection(params.keys())
if overlap:
raise ValueError("Extra parameters may not contain {}".format(overlap))
return params
class PostgreSQL(BaseSQLQueryRunner): class PostgreSQL(BaseSQLQueryRunner):
noop_query = "SELECT 1" noop_query = "SELECT 1"
@@ -160,7 +151,6 @@ class PostgreSQL(BaseSQLQueryRunner):
"host": {"type": "string", "default": "127.0.0.1"}, "host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 5432}, "port": {"type": "number", "default": 5432},
"dbname": {"type": "string", "title": "Database Name"}, "dbname": {"type": "string", "title": "Database Name"},
"dsn": {"type": "string", "default": "application_name=redash", "title": "Parameters"},
"sslmode": { "sslmode": {
"type": "string", "type": "string",
"title": "SSL Mode", "title": "SSL Mode",
@@ -215,15 +205,24 @@ class PostgreSQL(BaseSQLQueryRunner):
def _get_tables(self, schema): def _get_tables(self, schema):
""" """
relkind constants from https://www.postgresql.org/docs/current/catalog-pg-class.html relkind constants per https://www.postgresql.org/docs/10/static/catalog-pg-class.html
r = regular table
v = view
m = materialized view m = materialized view
f = foreign table
p = partitioned table (new in 10)
---
i = index
S = sequence
t = TOAST table
c = composite type
""" """
query = """ query = """
SELECT s.nspname AS table_schema, SELECT s.nspname as table_schema,
c.relname AS table_name, c.relname as table_name,
a.attname AS column_name, a.attname as column_name,
NULL AS data_type null as data_type
FROM pg_class c FROM pg_class c
JOIN pg_namespace s JOIN pg_namespace s
ON c.relnamespace = s.oid ON c.relnamespace = s.oid
@@ -232,8 +231,8 @@ class PostgreSQL(BaseSQLQueryRunner):
ON a.attrelid = c.oid ON a.attrelid = c.oid
AND a.attnum > 0 AND a.attnum > 0
AND NOT a.attisdropped AND NOT a.attisdropped
WHERE c.relkind = 'm' WHERE c.relkind IN ('m', 'f', 'p')
AND has_table_privilege(quote_ident(s.nspname) || '.' || quote_ident(c.relname), 'select') AND has_table_privilege(s.nspname || '.' || c.relname, 'select')
AND has_schema_privilege(s.nspname, 'usage') AND has_schema_privilege(s.nspname, 'usage')
UNION UNION
@@ -244,8 +243,6 @@ class PostgreSQL(BaseSQLQueryRunner):
data_type data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND has_table_privilege(quote_ident(table_schema) || '.' || quote_ident(table_name), 'select')
AND has_schema_privilege(table_schema, 'usage')
""" """
self._get_definitions(schema, query) self._get_definitions(schema, query)
@@ -254,7 +251,6 @@ class PostgreSQL(BaseSQLQueryRunner):
def _get_connection(self): def _get_connection(self):
self.ssl_config = _get_ssl_config(self.configuration) self.ssl_config = _get_ssl_config(self.configuration)
self.dsn = _parse_dsn(self.configuration)
connection = psycopg2.connect( connection = psycopg2.connect(
user=self.configuration.get("user"), user=self.configuration.get("user"),
password=self.configuration.get("password"), password=self.configuration.get("password"),
@@ -263,7 +259,6 @@ class PostgreSQL(BaseSQLQueryRunner):
dbname=self.configuration.get("dbname"), dbname=self.configuration.get("dbname"),
async_=True, async_=True,
**self.ssl_config, **self.ssl_config,
**self.dsn,
) )
return connection return connection

View File

@@ -1,14 +1,11 @@
try: try:
import snowflake.connector import snowflake.connector
from cryptography.hazmat.primitives.serialization import load_pem_private_key
enabled = True enabled = True
except ImportError: except ImportError:
enabled = False enabled = False
from base64 import b64decode
from redash import __version__ from redash import __version__
from redash.query_runner import ( from redash.query_runner import (
TYPE_BOOLEAN, TYPE_BOOLEAN,
@@ -46,8 +43,6 @@ class Snowflake(BaseSQLQueryRunner):
"account": {"type": "string"}, "account": {"type": "string"},
"user": {"type": "string"}, "user": {"type": "string"},
"password": {"type": "string"}, "password": {"type": "string"},
"private_key_File": {"type": "string"},
"private_key_pwd": {"type": "string"},
"warehouse": {"type": "string"}, "warehouse": {"type": "string"},
"database": {"type": "string"}, "database": {"type": "string"},
"region": {"type": "string", "default": "us-west"}, "region": {"type": "string", "default": "us-west"},
@@ -62,15 +57,13 @@ class Snowflake(BaseSQLQueryRunner):
"account", "account",
"user", "user",
"password", "password",
"private_key_File",
"private_key_pwd",
"warehouse", "warehouse",
"database", "database",
"region", "region",
"host", "host",
], ],
"required": ["user", "account", "database", "warehouse"], "required": ["user", "password", "account", "database", "warehouse"],
"secret": ["password", "private_key_File", "private_key_pwd"], "secret": ["password"],
"extra_options": [ "extra_options": [
"host", "host",
], ],
@@ -95,7 +88,7 @@ class Snowflake(BaseSQLQueryRunner):
if region == "us-west": if region == "us-west":
region = None region = None
if self.configuration.get("host"): if self.configuration.__contains__("host"):
host = self.configuration.get("host") host = self.configuration.get("host")
else: else:
if region: if region:
@@ -103,29 +96,14 @@ class Snowflake(BaseSQLQueryRunner):
else: else:
host = "{}.snowflakecomputing.com".format(account) host = "{}.snowflakecomputing.com".format(account)
params = { connection = snowflake.connector.connect(
"user": self.configuration["user"], user=self.configuration["user"],
"account": account, password=self.configuration["password"],
"region": region, account=account,
"host": host, region=region,
"application": "Redash/{} (Snowflake)".format(__version__.split("-")[0]), host=host,
} application="Redash/{} (Snowflake)".format(__version__.split("-")[0]),
)
if self.configuration.get("password"):
params["password"] = self.configuration["password"]
elif self.configuration.get("private_key_File"):
private_key_b64 = self.configuration.get("private_key_File")
private_key_bytes = b64decode(private_key_b64)
if self.configuration.get("private_key_pwd"):
private_key_pwd = self.configuration.get("private_key_pwd").encode()
else:
private_key_pwd = None
private_key_pem = load_pem_private_key(private_key_bytes, private_key_pwd)
params["private_key"] = private_key_pem
else:
raise Exception("Neither password nor private_key_b64 is set.")
connection = snowflake.connector.connect(**params)
return connection return connection

View File

@@ -1,6 +1,6 @@
import functools import functools
from flask import request, session from flask import session
from flask_login import current_user from flask_login import current_user
from flask_talisman import talisman from flask_talisman import talisman
from flask_wtf.csrf import CSRFProtect, generate_csrf from flask_wtf.csrf import CSRFProtect, generate_csrf
@@ -25,7 +25,6 @@ def init_app(app):
app.config["WTF_CSRF_CHECK_DEFAULT"] = False app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["WTF_CSRF_SSL_STRICT"] = False app.config["WTF_CSRF_SSL_STRICT"] = False
app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT
app.config["SESSION_COOKIE_NAME"] = settings.SESSION_COOKIE_NAME
@app.after_request @app.after_request
def inject_csrf_token(response): def inject_csrf_token(response):
@@ -36,15 +35,6 @@ def init_app(app):
@app.before_request @app.before_request
def check_csrf(): def check_csrf():
# BEGIN workaround until https://github.com/lepture/flask-wtf/pull/419 is merged
if request.blueprint in csrf._exempt_blueprints:
return
view = app.view_functions.get(request.endpoint)
if view is not None and f"{view.__module__}.{view.__name__}" in csrf._exempt_views:
return
# END workaround
if not current_user.is_authenticated or "user_id" in session: if not current_user.is_authenticated or "user_id" in session:
csrf.protect() csrf.protect()

View File

@@ -82,19 +82,9 @@ class QuerySerializer(Serializer):
else: else:
result = [serialize_query(query, **self.options) for query in self.object_or_list] result = [serialize_query(query, **self.options) for query in self.object_or_list]
if self.options.get("with_favorite_state", True): if self.options.get("with_favorite_state", True):
queries = list(self.object_or_list) favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list)
favorites = models.Favorite.query.filter(
models.Favorite.object_id.in_([o.id for o in queries]),
models.Favorite.object_type == "Query",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result: for query in result:
favorite = favorites_dict.get(query["id"]) query["is_favorite"] = query["id"] in favorite_ids
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result return result
@@ -273,19 +263,9 @@ class DashboardSerializer(Serializer):
else: else:
result = [serialize_dashboard(obj, **self.options) for obj in self.object_or_list] result = [serialize_dashboard(obj, **self.options) for obj in self.object_or_list]
if self.options.get("with_favorite_state", True): if self.options.get("with_favorite_state", True):
dashboards = list(self.object_or_list) favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list)
favorites = models.Favorite.query.filter( for obj in result:
models.Favorite.object_id.in_([o.id for o in dashboards]), obj["is_favorite"] = obj["id"] in favorite_ids
models.Favorite.object_type == "Dashboard",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result:
favorite = favorites_dict.get(query["id"])
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result return result

View File

@@ -82,7 +82,6 @@ SESSION_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_SECU
# Whether the session cookie is set HttpOnly. # Whether the session cookie is set HttpOnly.
SESSION_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true")) 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_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. # Whether the session cookie is set to secure.
REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE)) REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE))
@@ -136,13 +135,6 @@ FEATURE_POLICY = os.environ.get("REDASH_FEATURE_POLICY", "")
MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false")) 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_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "") GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
@@ -348,7 +340,6 @@ default_query_runners = [
"redash.query_runner.oracle", "redash.query_runner.oracle",
"redash.query_runner.e6data", "redash.query_runner.e6data",
"redash.query_runner.risingwave", "redash.query_runner.risingwave",
"redash.query_runner.duckdb",
] ]
enabled_query_runners = array_from_string( enabled_query_runners = array_from_string(

View File

@@ -27,7 +27,6 @@ DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm") TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm")
INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0") INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0")
FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00") 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")) 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")) JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false"))
@@ -60,7 +59,6 @@ settings = {
"time_format": TIME_FORMAT, "time_format": TIME_FORMAT,
"integer_format": INTEGER_FORMAT, "integer_format": INTEGER_FORMAT,
"float_format": FLOAT_FORMAT, "float_format": FLOAT_FORMAT,
"null_value": NULL_VALUE,
"multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED, "multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED,
"auth_jwt_login_enabled": JWT_LOGIN_ENABLED, "auth_jwt_login_enabled": JWT_LOGIN_ENABLED,
"auth_jwt_auth_issuer": JWT_AUTH_ISSUER, "auth_jwt_auth_issuer": JWT_AUTH_ISSUER,

View File

@@ -9,7 +9,6 @@ from redash.models.parameterized_query import (
QueryDetachedFromDataSourceError, QueryDetachedFromDataSourceError,
) )
from redash.monitor import rq_job_ids from redash.monitor import rq_job_ids
from redash.query_runner import NotSupported
from redash.tasks.failure_report import track_failure from redash.tasks.failure_report import track_failure
from redash.utils import json_dumps, sentry from redash.utils import json_dumps, sentry
from redash.worker import get_job_logger, job from redash.worker import get_job_logger, job
@@ -178,8 +177,6 @@ def refresh_schema(data_source_id):
time.time() - start_time, time.time() - start_time,
) )
statsd_client.incr("refresh_schema.timeout") statsd_client.incr("refresh_schema.timeout")
except NotSupported:
logger.debug("Datasource %s does not support schema refresh", ds.name)
except Exception: except Exception:
logger.warning("Failed refreshing schema for the data source: %s", ds.name, exc_info=1) logger.warning("Failed refreshing schema for the data source: %s", ds.name, exc_info=1)
statsd_client.incr("refresh_schema.error") statsd_client.incr("refresh_schema.error")

View File

@@ -211,7 +211,7 @@ def collect_parameters_from_request(args):
def base_url(org): def base_url(org):
if settings.MULTI_ORG: if settings.MULTI_ORG:
return "{}/{}".format(settings.HOST, org.slug) return "https://{}/{}".format(settings.HOST, org.slug)
return settings.HOST return settings.HOST

View File

@@ -1,50 +0,0 @@
import json
from unittest import mock
from redash.destinations.webhook import Webhook
from redash.models import Alert
def test_webhook_notify_handles_unicode():
# Create a mock alert with all the properties needed by serialize_alert
alert = mock.Mock()
alert.id = 1
alert.name = "Test Alert"
alert.custom_subject = "Test Subject With Unicode: 晨"
alert.custom_body = "Test Body"
alert.options = {}
alert.state = "ok"
alert.last_triggered_at = None
alert.updated_at = "2025-12-02T08:00:00Z"
alert.created_at = "2025-12-02T08:00:00Z"
alert.rearm = None
alert.query_id = 10
alert.user_id = 20
query = mock.Mock()
user = mock.Mock()
app = mock.Mock()
host = "http://redash.local"
options = {"url": "https://example.com/webhook", "username": "user", "password": "password"}
metadata = {}
new_state = Alert.TRIGGERED_STATE
destination = Webhook(options)
with mock.patch("redash.destinations.webhook.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)
# Get the data passed to the mock
call_args, call_kwargs = mock_post.call_args
sent_data = call_kwargs["data"]
# 1. Make sure we send bytes
assert isinstance(sent_data, bytes)
# 2. Make sure the bytes are the correct UTF-8 encoded JSON
decoded_data = json.loads(sent_data.decode("utf-8"))
assert decoded_data["alert"]["title"] == alert.custom_subject
assert "Test Subject With Unicode: 晨" in sent_data.decode("utf-8")

View File

@@ -1,7 +1,6 @@
import textwrap import textwrap
from unittest import TestCase from unittest import TestCase
from redash import settings
from redash.models import OPERATORS, Alert, db, next_state from redash.models import OPERATORS, Alert, db, next_state
from tests import BaseTestCase from tests import BaseTestCase
@@ -177,18 +176,16 @@ class TestAlertRenderTemplate(BaseTestCase):
ALERT_CONDITION equals ALERT_CONDITION equals
ALERT_THRESHOLD 5 ALERT_THRESHOLD 5
ALERT_NAME %s ALERT_NAME %s
ALERT_URL %s/default/alerts/%d ALERT_URL https:///default/alerts/%d
QUERY_NAME Query QUERY_NAME Query
QUERY_URL %s/default/queries/%d QUERY_URL https:///default/queries/%d
QUERY_RESULT_VALUE 1 QUERY_RESULT_VALUE 1
QUERY_RESULT_ROWS [{'foo': 1}] QUERY_RESULT_ROWS [{'foo': 1}]
QUERY_RESULT_COLS [{'name': 'foo', 'type': 'STRING'}] QUERY_RESULT_COLS [{'name': 'foo', 'type': 'STRING'}]
</pre> </pre>
""" % ( """ % (
alert.name, alert.name,
settings.HOST,
alert.id, alert.id,
settings.HOST,
alert.query_id, alert.query_id,
) )
result = alert.render_template(textwrap.dedent(custom_alert)) result = alert.render_template(textwrap.dedent(custom_alert))

View File

@@ -1,42 +0,0 @@
from unittest import TestCase
from unittest.mock import patch
from redash.query_runner.azure_kusto import AzureKusto
class TestAzureKusto(TestCase):
def setUp(self):
self.configuration = {
"cluster": "https://example.kusto.windows.net",
"database": "sample_db",
"azure_ad_client_id": "client_id",
"azure_ad_client_secret": "client_secret",
"azure_ad_tenant_id": "tenant_id",
}
self.kusto = AzureKusto(self.configuration)
@patch.object(AzureKusto, "run_query")
def test_get_schema(self, mock_run_query):
mock_response = {
"rows": [
{
"DatabaseSchema": '{"Databases":{"sample_db":{"Tables":{"Table1":{"Name":"Table1","OrderedColumns":[{"Name":"Column1","Type":"System.String","CslType":"string"},{"Name":"Column2","Type":"System.DateTime","CslType":"datetime"}]}},"MaterializedViews":{"View1":{"Name":"View1","OrderedColumns":[{"Name":"Column1","Type":"System.String","CslType":"string"},{"Name":"Column2","Type":"System.DateTime","CslType":"datetime"}]}}}}}'
}
]
}
mock_run_query.return_value = (mock_response, None)
expected_schema = [
{
"name": "Table1",
"columns": [{"name": "Column1", "type": "string"}, {"name": "Column2", "type": "datetime"}],
},
{
"name": "View1",
"columns": [{"name": "Column1", "type": "string"}, {"name": "Column2", "type": "datetime"}],
},
]
schema = self.kusto.get_schema()
print(schema)
self.assertEqual(schema, expected_schema)

View File

@@ -20,7 +20,7 @@ class TestBigQueryQueryRunner(unittest.TestCase):
query = "SELECT a FROM tbl" query = "SELECT a FROM tbl"
expect = ( expect = (
"/* Username: username, query_id: adhoc, " "/* Username: username, query_id: adhoc, "
"Query Hash: query-hash, " "Job ID: job-id, Query Hash: query-hash, "
"Scheduled: False */ SELECT a FROM tbl" "Scheduled: False */ SELECT a FROM tbl"
) )

View File

@@ -1,107 +0,0 @@
from unittest import TestCase
from unittest.mock import patch
from redash.query_runner.duckdb import DuckDB
class TestDuckDBSchema(TestCase):
def setUp(self) -> None:
self.runner = DuckDB({"dbpath": ":memory:"})
@patch.object(DuckDB, "run_query")
def test_simple_schema_build(self, mock_run_query) -> None:
# Simulate queries: first for tables, then for DESCRIBE
mock_run_query.side_effect = [
(
{"rows": [{"table_schema": "main", "table_name": "users"}]},
None,
),
(
{
"rows": [
{"column_name": "id", "column_type": "INTEGER"},
{"column_name": "name", "column_type": "VARCHAR"},
]
},
None,
),
]
schema = self.runner.get_schema()
self.assertEqual(len(schema), 1)
self.assertEqual(schema[0]["name"], "main.users")
self.assertListEqual(
schema[0]["columns"],
[{"name": "id", "type": "INTEGER"}, {"name": "name", "type": "VARCHAR"}],
)
@patch.object(DuckDB, "run_query")
def test_struct_column_expansion(self, mock_run_query) -> None:
# First call to run_query -> tables list
mock_run_query.side_effect = [
(
{"rows": [{"table_schema": "main", "table_name": "events"}]},
None,
),
# Second call -> DESCRIBE output
(
{
"rows": [
{
"column_name": "payload",
"column_type": "STRUCT(a INTEGER, b VARCHAR)",
}
]
},
None,
),
]
schema_list = self.runner.get_schema()
self.assertEqual(len(schema_list), 1)
schema = schema_list[0]
# Ensure both raw and expanded struct fields are present
self.assertIn("main.events", schema["name"])
self.assertListEqual(
schema["columns"],
[
{"name": "payload", "type": "STRUCT(a INTEGER, b VARCHAR)"},
{"name": "payload.a", "type": "INTEGER"},
{"name": "payload.b", "type": "VARCHAR"},
],
)
def test_nested_struct_expansion(self) -> None:
runner = DuckDB({"dbpath": ":memory:"})
runner.con.execute(
"""
CREATE TABLE sample_struct_table (
id INTEGER,
info STRUCT(
name VARCHAR,
metrics STRUCT(score DOUBLE, rank INTEGER),
tags STRUCT(primary_tag VARCHAR, secondary_tag VARCHAR)
)
);
"""
)
schema = runner.get_schema()
table = next(t for t in schema if t["name"] == "main.sample_struct_table")
colnames = [c["name"] for c in table["columns"]]
assert "info" in colnames
assert 'info."name"' in colnames
assert "info.metrics" in colnames
assert "info.metrics.score" in colnames
assert "info.metrics.rank" in colnames
assert "info.tags.primary_tag" in colnames
assert "info.tags.secondary_tag" in colnames
@patch.object(DuckDB, "run_query")
def test_error_propagation(self, mock_run_query) -> None:
mock_run_query.return_value = (None, "boom")
with self.assertRaises(Exception) as ctx:
self.runner.get_schema()
self.assertIn("boom", str(ctx.exception))

View File

@@ -1,16 +1,6 @@
from unittest import TestCase from unittest import TestCase
from redash.query_runner.pg import _parse_dsn, build_schema from redash.query_runner.pg import build_schema
class TestParameters(TestCase):
def test_parse_dsn(self):
configuration = {"dsn": "application_name=redash connect_timeout=5"}
self.assertDictEqual(_parse_dsn(configuration), {"application_name": "redash", "connect_timeout": "5"})
def test_parse_dsn_not_permitted(self):
configuration = {"dsn": "password=xyz"}
self.assertRaises(ValueError, _parse_dsn, configuration)
class TestBuildSchema(TestCase): class TestBuildSchema(TestCase):

View File

@@ -28,4 +28,4 @@ class TestJsonDumps(BaseTestCase):
} }
json_data = json_dumps(input_data) json_data = json_dumps(input_data)
actual_output_data = json_loads(json_data) actual_output_data = json_loads(json_data)
self.assertEqual(actual_output_data, expected_output_data) self.assertEquals(actual_output_data, expected_output_data)

View File

@@ -46,7 +46,7 @@
"@types/jest": "^26.0.18", "@types/jest": "^26.0.18",
"@types/leaflet": "^1.5.19", "@types/leaflet": "^1.5.19",
"@types/numeral": "0.0.28", "@types/numeral": "0.0.28",
"@types/plotly.js": "^3.0.3", "@types/plotly.js": "^2.35.2",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"@types/tinycolor2": "^1.4.2", "@types/tinycolor2": "^1.4.2",
@@ -62,7 +62,7 @@
"less-loader": "^11.1.3", "less-loader": "^11.1.3",
"less-plugin-autoprefix": "^2.0.0", "less-plugin-autoprefix": "^2.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "3.3.2", "prettier": "^1.19.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"style-loader": "^3.3.3", "style-loader": "^3.3.3",
"ts-migrate": "^0.1.35", "ts-migrate": "^0.1.35",
@@ -91,7 +91,7 @@
"leaflet.markercluster": "^1.1.0", "leaflet.markercluster": "^1.1.0",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"plotly.js": "3.1.0", "plotly.js": "2.35.3",
"react-pivottable": "^0.9.0", "react-pivottable": "^0.9.0",
"react-sortable-hoc": "^1.10.1", "react-sortable-hoc": "^1.10.1",
"tinycolor2": "^1.4.1", "tinycolor2": "^1.4.1",

View File

@@ -5,7 +5,6 @@ import numeral from "numeral";
import { isString, isArray, isUndefined, isFinite, isNil, toString } from "lodash"; import { isString, isArray, isUndefined, isFinite, isNil, toString } from "lodash";
import { visualizationsSettings } from "@/visualizations/visualizationsSettings"; import { visualizationsSettings } from "@/visualizations/visualizationsSettings";
numeral.options.scalePercentBy100 = false; numeral.options.scalePercentBy100 = false;
// eslint-disable-next-line // eslint-disable-next-line
@@ -13,16 +12,9 @@ const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019
const hasOwnProperty = Object.prototype.hasOwnProperty; const hasOwnProperty = Object.prototype.hasOwnProperty;
function NullValueComponent() {
return <span className="display-as-null">{visualizationsSettings.nullValue}</span>;
}
export function createTextFormatter(highlightLinks: any) { export function createTextFormatter(highlightLinks: any) {
if (highlightLinks) { if (highlightLinks) {
return (value: any) => { return (value: any) => {
if (value === null) {
return <NullValueComponent/>
}
if (isString(value)) { if (isString(value)) {
const Link = visualizationsSettings.LinkComponent; const Link = visualizationsSettings.LinkComponent;
value = value.replace(urlPattern, (unused, prefix, href) => { value = value.replace(urlPattern, (unused, prefix, href) => {
@@ -37,7 +29,7 @@ export function createTextFormatter(highlightLinks: any) {
return toString(value); return toString(value);
}; };
} }
return (value: any) => value === null ? <NullValueComponent/> : toString(value); return (value: any) => toString(value);
} }
function toMoment(value: any) { function toMoment(value: any) {
@@ -54,14 +46,11 @@ function toMoment(value: any) {
export function createDateTimeFormatter(format: any) { export function createDateTimeFormatter(format: any) {
if (isString(format) && format !== "") { if (isString(format) && format !== "") {
return (value: any) => { return (value: any) => {
if (value === null) {
return <NullValueComponent/>;
}
const wrapped = toMoment(value); const wrapped = toMoment(value);
return wrapped.isValid() ? wrapped.format(format) : toString(value); return wrapped.isValid() ? wrapped.format(format) : toString(value);
}; };
} }
return (value: any) => value === null ? <NullValueComponent/> : toString(value); return (value: any) => toString(value);
} }
export function createBooleanFormatter(values: any) { export function createBooleanFormatter(values: any) {
@@ -69,9 +58,6 @@ export function createBooleanFormatter(values: any) {
if (values.length >= 2) { if (values.length >= 2) {
// Both `true` and `false` specified // Both `true` and `false` specified
return (value: any) => { return (value: any) => {
if (value === null) {
return <NullValueComponent/>;
}
if (isNil(value)) { if (isNil(value)) {
return ""; return "";
} }
@@ -83,9 +69,6 @@ export function createBooleanFormatter(values: any) {
} }
} }
return (value: any) => { return (value: any) => {
if (value === null) {
return <NullValueComponent/>;
}
if (isNil(value)) { if (isNil(value)) {
return ""; return "";
} }
@@ -93,20 +76,12 @@ export function createBooleanFormatter(values: any) {
}; };
} }
export function createNumberFormatter(format: any, canReturnHTMLElement: boolean = false) { export function createNumberFormatter(format: any) {
if (isString(format) && format !== "") { if (isString(format) && format !== "") {
const n = numeral(0); // cache `numeral` instance const n = numeral(0); // cache `numeral` instance
return (value: any) => { return (value: any) => (value === null || value === "" ? "" : n.set(value).format(format));
if (canReturnHTMLElement && value === null) {
return <NullValueComponent/>;
} }
if (value === "" || value === null) { return (value: any) => toString(value);
return "";
}
return n.set(value).format(format);
}
}
return (value: any) => (canReturnHTMLElement && value === null) ? <NullValueComponent/> : toString(value);
} }
export function formatSimpleTemplate(str: any, data: any) { export function formatSimpleTemplate(str: any, data: any) {

View File

@@ -6,7 +6,7 @@ import { EditorPropTypes } from "@/visualizations/prop-types";
const defaultCustomCode = trimStart(` const defaultCustomCode = trimStart(`
// Available variables are x, ys, element, and Plotly // Available variables are x, ys, element, and Plotly
// Type console.log(x, ys); for more info about x and ys // Type console.log(x, ys); for more info about x and ys
// To plot your graph call Plotly.newPlot(element, ...) // To plot your graph call Plotly.plot(element, ...)
// Plotly examples and docs: https://plot.ly/javascript/ // Plotly examples and docs: https://plot.ly/javascript/
`); `);

View File

@@ -336,39 +336,7 @@ export default function GeneralSettings({ options, data, onOptionsChange }: any)
</Section> </Section>
)} )}
{includes(["line", "area"], options.globalSeriesType) && ( {!includes(["custom", "heatmap", "bubble", "scatter"], options.globalSeriesType) && (
// @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message
<Section>
<Select
label="Line Shape"
data-test="Chart.LineShape"
defaultValue={options.lineShape}
onChange={(val: any) => onOptionsChange({ lineShape: val })}>
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
<Select.Option value="linear" data-test="Chart.LineShape.Linear">
Linear
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
<Select.Option value="spline" data-test="Chart.LineShape.Spline">
Spline
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
<Select.Option value="hv" data-test="Chart.LineShape.HorizontalVertical">
Horizontal-Vertical
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
<Select.Option value="vh" data-test="Chart.LineShape.VerticalHorizontal">
Vertical-Horizontal
{/* @ts-expect-error ts-migrate(2339) FIXME: Property 'Option' does not exist on type '({ class... Remove this comment to see the full error message */}
</Select.Option>
</Select>
</Section>
)}
{!includes(["custom", "heatmap", "bubble"], options.globalSeriesType) && (
// @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message // @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message
<Section> <Section>
<Select <Select

View File

@@ -18,7 +18,6 @@ const DEFAULT_OPTIONS = {
coefficient: 1, coefficient: 1,
piesort: true, piesort: true,
color_scheme: "Redash", color_scheme: "Redash",
lineShape: "linear",
// showDataLabels: false, // depends on chart type // showDataLabels: false, // depends on chart type
numberFormat: "0,0[.]00000", numberFormat: "0,0[.]00000",

View File

@@ -10,7 +10,7 @@ export default {
Renderer, Renderer,
Editor, Editor,
defaultColumns: 6, defaultColumns: 3,
defaultRows: 8, defaultRows: 8,
minColumns: 1, minColumns: 1,
minRows: 5, minRows: 5,

View File

@@ -48,6 +48,7 @@
"series": [ "series": [
{ {
"visible": true, "visible": true,
"offsetgroup": "0",
"type": "bar", "type": "bar",
"name": "a", "name": "a",
"x": ["x1", "x2", "x3", "x4"], "x": ["x1", "x2", "x3", "x4"],
@@ -63,6 +64,7 @@
}, },
{ {
"visible": true, "visible": true,
"offsetgroup": "1",
"type": "bar", "type": "bar",
"name": "b", "name": "b",
"x": ["x1", "x2", "x3", "x4"], "x": ["x1", "x2", "x3", "x4"],

View File

@@ -48,6 +48,7 @@
"series": [ "series": [
{ {
"visible": true, "visible": true,
"offsetgroup": "0",
"type": "bar", "type": "bar",
"name": "a", "name": "a",
"x": ["x1", "x2", "x3", "x4"], "x": ["x1", "x2", "x3", "x4"],
@@ -63,6 +64,7 @@
}, },
{ {
"visible": true, "visible": true,
"offsetgroup": "1",
"type": "bar", "type": "bar",
"name": "b", "name": "b",
"x": ["x1", "x2", "x3", "x4"], "x": ["x1", "x2", "x3", "x4"],

View File

@@ -20,8 +20,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": true, "missingValuesAsZero": true
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -47,7 +46,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

View File

@@ -21,8 +21,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": false, "missingValuesAsZero": false
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -55,7 +54,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"
@@ -70,7 +68,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["", "2 ± 0", "", "4 ± 0"], "text": ["", "2 ± 0", "", "4 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "blue" }, "marker": { "color": "blue" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

View File

@@ -21,8 +21,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": true, "missingValuesAsZero": true
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -55,7 +54,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"
@@ -70,7 +68,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["0", "2 ± 0", "0", "4 ± 0"], "text": ["0", "2 ± 0", "0", "4 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "blue" }, "marker": { "color": "blue" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

View File

@@ -21,8 +21,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": true, "missingValuesAsZero": true
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -57,7 +56,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"
@@ -72,7 +70,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"],
"line": { "shape": "linear" },
"marker": { "color": "blue" }, "marker": { "color": "blue" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

View File

@@ -21,8 +21,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": true, "missingValuesAsZero": true
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -57,7 +56,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"
@@ -72,7 +70,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"],
"line": { "shape": "linear" },
"marker": { "color": "blue" }, "marker": { "color": "blue" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

View File

@@ -21,8 +21,7 @@
"x": "x", "x": "x",
"y1": "y" "y1": "y"
}, },
"missingValuesAsZero": true, "missingValuesAsZero": true
"lineShape": "linear"
}, },
"data": [ "data": [
{ {
@@ -57,7 +56,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "red" }, "marker": { "color": "red" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"
@@ -72,7 +70,6 @@
"hoverinfo": "text+x+name", "hoverinfo": "text+x+name",
"hover": [], "hover": [],
"text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"], "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"],
"line": { "shape": "linear" },
"marker": { "color": "blue" }, "marker": { "color": "blue" },
"insidetextfont": { "color": "#ffffff" }, "insidetextfont": { "color": "#ffffff" },
"yaxis": "y" "yaxis": "y"

Some files were not shown because too many files have changed in this diff Show More