Compare commits

...

33 Commits

Author SHA1 Message Date
github-actions[bot]
772b160a79 Snapshot: 25.11.0-dev 2025-11-01 00:39:20 +00:00
Tsuneo Yoshioka
bac2160e2a Advanced query search syntax for multi byte search (#7546)
* Advanced query search syntax for multi byte search

* Advanced search for my queries

* Add advanced query seearch tooltip

* Revert "Add advanced query seearch tooltip"

This reverts commit 43148ba6ac.
2025-10-15 16:12:00 +00:00
Tsuneo Yoshioka
c5aa5da6a2 Update queries.latest_query_data on save (#7560)
* Update queries.latest_query_data on save

* Add wait on test as loading query and query results may re-render DOM and that makes test fraky

* Fix styling report by prettier
2025-10-14 22:59:36 +09:00
Eric Radman
9503cc9fb8 Correct custom chart help text: use newPlot() (#7557) 2025-10-08 07:44:29 -04:00
Tsuneo Yoshioka
b353057f9a Update ace-builds/react-ace to the latest versions (#7532) 2025-10-06 11:14:37 -04:00
Eric Radman
8747d02bbe Use standard PostgreSQL image and drop clean-all target (#7555) 2025-10-06 09:48:49 -04:00
Kamil Frydel
5b463b0d83 Make details visualization configurable (#7535)
- Added possibility to select visible columns and reordering
- Added formatting options as in Table visualization
- Set default alignment to left
2025-10-06 08:10:33 -04:00
Tsuneo Yoshioka
ea589ad477 Query Serach: avoid concurrent search API request (#7551) 2025-10-02 14:52:57 +00:00
Tsuneo Yoshioka
617124850b SchemaBrowser: on column comment tooltip, show newlines correctly (#7552) 2025-10-02 23:22:01 +09:00
Zafer Balkan
1cc200843c Add duckdb support (#7548) 2025-10-01 23:27:13 +09:00
github-actions[bot]
e0410e2ffe Snapshot: 25.10.0-dev 2025-10-01 00:39:34 +00:00
Tsuneo Yoshioka
7e39b3668d MySQL: add column type, comment, and table comment (#7544) 2025-09-30 19:20:34 +00:00
Tsuneo Yoshioka
92f15a3ccb BigQuery: Add table description on Schema Browser (#7543) 2025-10-01 03:52:36 +09:00
Tsuneo Yoshioka
9a1d33381c BigQuery: support multiple locations (#7540) 2025-09-25 22:01:17 +09:00
Tsuneo Yoshioka
56c06adc24 BigQuery: Remove "Job ID" metadata on annotaton to avoid cache misses (#7541) 2025-09-24 23:44:40 +09:00
Tsuneo Yoshioka
5e8915afe5 BigQuery: show column description(comment) on Schema Browser (#7538)
* BigQuery: add column description to Schema Browser

* fix restyled prettier error

* Remove column-description
2025-09-23 23:54:01 +09:00
Tsuneo Yoshioka
b8ebf49436 Make favorite queries/dashboard order by starred at(favorited at) (#7351)
* Make favorite queries/dashboard order by starred at(favorited at)

* fix styling for restyled error
2025-09-16 16:24:03 +09:00
Tsuneo Yoshioka
59951eda3d Fix/too many history replace state (#7530)
* Fix too many history.replaceState() error on Safari

* fix restyled error by running prettier for client/app/services/location.js
2025-09-12 03:41:04 +09:00
Artem Safiiulin
777153e7a0 Update jql.py (jira datasource) to use jira api v3 updated. (#7527)
* Update jql.py (jira datasource) to use jira api v3 updated.

* fix spaces in blank lines

* Add condition for empty "fields"

---------

Co-authored-by: Artem Safiiulin <asafiiulin@cloudlinux.com>
Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-09-10 23:43:05 +09:00
Tsuneo Yoshioka
47b1309f13 Add range slider to the chart (#7525) 2025-09-09 19:22:53 +00:00
Tsuneo Yoshioka
120250152f Add "Missing and NULL values" option to scatter chart (#7523) 2025-09-08 23:54:11 +00:00
Tsuneo Yoshioka
ac81f0b223 keep ordering on search (#7520) 2025-09-08 17:22:35 +00:00
Tsuneo Yoshioka
7838058953 fix: webpack missing source-map warning for @plotly/msgbox-gl (#7522) 2025-09-08 14:18:10 +00:00
Eric Radman
f95156e924 Rely on information_schema.columns for views and foreign tables (#7521)
This prevents duplicate entries in the schema list.  Materialized views are the
only table-like object not found information_schema. Also ensure that the schema
and table found in information_schema is accessible by the current user.
2025-09-08 09:41:28 -04:00
Tsuneo Yoshioka
74de676bdf Allow HTTP request line more than 4096 bytes (#7506) 2025-09-04 18:05:05 +00:00
Tsuneo Yoshioka
2762f1fc85 Fix: null is not shown for text with "Allow HTML content" (#7519) 2025-09-03 10:55:26 -04:00
github-actions[bot]
438efd0826 Snapshot: 25.09.0-dev 2025-09-01 00:43:37 +00:00
Tsuneo Yoshioka
e586ab708b Fix stacking bar chart (#7516) 2025-08-30 03:31:47 +09:00
Tsuneo Yoshioka
24ca5135aa Update plotly.js to 3.1.0 (#7514) 2025-08-28 17:06:54 +09:00
Eric Radman
fae354fcce Update Poetry to 2.1.4 (#7509)
* Relocate [tool.poetry] to [project] section
* Add dependencies section to [project]
* Format authors and maintainers as objects
2025-08-26 08:08:19 -04:00
Tsuneo Yoshioka
4ae372f022 Update from webpack4 to webpack5 (#7507) 2025-08-25 20:50:18 +00:00
Tsuneo Yoshioka
0b5907f12b Fix css height for mobile safari not to overlap URL bar (#7334) 2025-08-22 18:42:36 +00:00
Adrian Oesch
00a97d9266 Add private_key auth method to snowflake query runner (#7371)
* add private_key auth method

* fix casing

* fix private_key parsing

* use params and add optional pwd

* use private_key_b64

* add file option

* remove __contains__

* fix pem pwd

* fix lint issues

* fix black

---------

Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-08-08 17:38:22 +00:00
102 changed files with 3754 additions and 1991 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: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
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: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
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

@@ -95,7 +95,7 @@ EOF
WORKDIR /app WORKDIR /app
ENV POETRY_VERSION=1.8.3 ENV POETRY_VERSION=2.1.4
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 clean-all 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 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,11 +32,6 @@ 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 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}
} }
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(100vh - 116px); height: calc(100% - 116px);
} }
@media (min-width: 992px) { @media (min-width: 992px) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -20,7 +20,7 @@ html {
html, html,
body { body {
min-height: 100vh; height: 100%;
} }
body { body {
@@ -35,7 +35,7 @@ body {
} }
#application-root { #application-root {
min-height: 100vh; height: 100%;
} }
#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: 100vh; height: 100%;
} }
} }
@@ -135,4 +135,4 @@
} }
} }

View File

@@ -8,7 +8,7 @@ body.fixed-layout {
padding-bottom: 0; padding-bottom: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100%;
.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(~'100vh - 25px'); height: calc(~'100% - 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: 100vh; height: 100%;
.application-layout-side-menu { .application-layout-side-menu {
height: 100vh; height: 100%;
position: relative; position: relative;
@media @mobileBreakpoint { @media @mobileBreakpoint {
@@ -47,6 +47,10 @@ 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

@@ -10,6 +10,10 @@ 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)
@@ -18,7 +22,7 @@ export interface Controller<I, P = any> {
// search // search
searchTerm?: string; searchTerm?: string;
updateSearch: (searchTerm: string) => void; updateSearch: (searchTerm: string, searchOptions?: SearchOptions) => void;
// tags // tags
selectedTags: string[]; selectedTags: string[];
@@ -94,7 +98,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: () => StateStorage createStateStorage: ( { ...props }) => 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;
@@ -117,7 +121,7 @@ export function wrap<I, P = any>(
constructor(props: ItemsListWrapperProps) { constructor(props: ItemsListWrapperProps) {
super(props); super(props);
const stateStorage = createStateStorage(); const stateStorage = createStateStorage({ ...props });
const itemsSource = createItemsSource(); const itemsSource = createItemsSource();
this._itemsSource = itemsSource; this._itemsSource = itemsSource;
@@ -141,11 +145,32 @@ export function wrap<I, P = any>(
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, setSorting, 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 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

@@ -135,19 +135,19 @@ export class ItemsSource {
this._changed({ sorting: true }); this._changed({ sorting: true });
}; };
updateSearch = (searchTerm) => { 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 === "") { if (searchTerm === "" || !options?.isServerSideFTS) {
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);
this._changed({ search: true, pagination: { page: true } }); return this._changed({ search: true, pagination: { page: true } });
}; };
updateSelectedTags = (selectedTags) => { updateSelectedTags = (selectedTags) => {

View File

@@ -47,20 +47,30 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
return ( return (
<div {...props}> <div {...props}>
<div className="schema-list-item"> <div className="schema-list-item">
<PlainButton className="table-name" onClick={onToggle}> <Tooltip
<i className="fa fa-table m-r-5" aria-hidden="true" /> title={item.description}
<strong> mouseEnterDelay={0}
<span title={item.name}>{tableDisplayName}</span> mouseLeaveDelay={0}
{!isNil(item.size) && <span> ({item.size})</span>} placement="rightTop"
</strong> trigger={item.description ? "hover" : ""}
</PlainButton> overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton className="table-name" onClick={onToggle}>
<i className="fa fa-table m-r-5" aria-hidden="true" />
<strong>
<span title={item.name}>{tableDisplayName}</span>
{!isNil(item.size) && <span> ({item.size})</span>}
</strong>
</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>
@@ -70,16 +80,23 @@ 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" title={"Insert column name into query text" + (columnDescription ? "\n" + columnDescription : "")}
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="rightTop"> placement="rightTop"
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}> overlayStyle={{ whiteSpace: "pre-line" }}
>
<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>
@@ -168,7 +185,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) {
@@ -181,9 +198,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))
); );
} }
@@ -191,11 +208,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;
} }
@@ -243,7 +260,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

@@ -81,12 +81,19 @@ 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, listColumns, DashboardListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, DashboardListExtraActions);
return ( return (
<div className="page-dashboard-list"> <div className="page-dashboard-list">
@@ -139,9 +146,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>
@@ -170,10 +177,10 @@ const DashboardListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return item => new Dashboard(item); return (item) => new Dashboard(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -181,7 +188,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(
@@ -189,7 +196,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/favorites", path: "/dashboards/favorites",
title: "Favorite Dashboards", title: "Favorite Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(
@@ -197,6 +204,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

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

View File

@@ -15,7 +15,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
resource resource
.favorites() .favorites({ order: "-starred_at" })
.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, { useEffect, useRef } from "react"; import React, { useCallback, 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 { currentUser } from "@/services/auth"; import { clientConfig, 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,25 +95,39 @@ 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) {
controllerRef.current.updateSearch(searchTerm); 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, listColumns, QueriesListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, QueriesListExtraActions);
return ( return (
<div className="page-queries-list"> <div className="page-queries-list">
@@ -135,7 +149,7 @@ function QueriesList({ controller }) {
placeholder="Search Queries..." placeholder="Search Queries..."
label="Search queries" label="Search queries"
value={controller.searchTerm} value={controller.searchTerm}
onChange={controller.updateSearch} onChange={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 />
@@ -200,7 +214,7 @@ const QueriesListPage = itemsList(
return (item) => new Query(item); return (item) => new Query(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -216,7 +230,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/favorites", path: "/queries/favorites",
title: "Favorite Queries", title: "Favorite Queries",
render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(

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,12 +60,18 @@ 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) {
history.replace(newLocation); if (
newLocation.pathname !== location.path ||
newLocation.search !== qs.stringify(location.search) ||
newLocation.hash !== location.hash
) {
history.replace(newLocation);
}
} else { } else {
history.push(newLocation); history.push(newLocation);
} }

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 } }))
@@ -32,26 +32,23 @@ describe("Dashboard Filters", () => {
}); });
}); });
it("filters rows in a Table Visualization", function() { it("filters rows in a Table Visualization", function () {
editDashboard(); editDashboard();
cy.getByTestId("DashboardFilters").should("not.exist"); cy.getByTestId("DashboardFilters").should("not.exist");
cy.getByTestId("DashboardFiltersCheckbox").click(); cy.getByTestId("DashboardFiltersCheckbox").click();
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select-selection-item").should("have.text", "a");
.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") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.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(() => {
@@ -69,14 +66,13 @@ 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") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.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

@@ -5,8 +5,9 @@ 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(`
@@ -15,7 +16,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 });
@@ -23,9 +24,7 @@ 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") cy.get(".ant-dropdown-menu-item").should("exist").should("not.contain", "Show API Key");
.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");
@@ -42,8 +41,9 @@ 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,6 +44,7 @@ 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();
}); });
}); });
@@ -61,9 +62,7 @@ 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") cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.find("svg")
.should("exist");
cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] });
}); });

View File

@@ -31,6 +31,7 @@ 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 =
@@ -109,6 +110,7 @@ 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");
@@ -123,6 +125,7 @@ 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,6 +34,7 @@ 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();
@@ -76,9 +77,7 @@ 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") cy.getByTestId("VisualizationPreview").find(".map-visualization-container.leaflet-container").should("exist");
.find(".map-visualization-container.leaflet-container")
.should("exist");
cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] });
}); });

View File

@@ -24,6 +24,7 @@ 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();
@@ -51,9 +52,7 @@ 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") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -64,9 +63,7 @@ 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") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -12,6 +12,7 @@ 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();
@@ -24,9 +25,7 @@ describe("Counter", () => {
Counter.General.ValueColumn.a Counter.General.ValueColumn.a
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -43,9 +42,7 @@ describe("Counter", () => {
"Counter.General.Label": "Custom Label", "Counter.General.Label": "Custom Label",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -65,9 +62,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -83,9 +78,7 @@ describe("Counter", () => {
Counter.General.TargetValueColumn.b Counter.General.TargetValueColumn.b
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -106,9 +99,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -123,9 +114,7 @@ describe("Counter", () => {
Counter.General.CountRows Counter.General.CountRows
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -151,9 +140,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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
@@ -180,9 +167,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.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,34 +5,25 @@ 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") cy.getByTestId("NewVisualization").should("exist").click();
.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") cy.getByTestId("VisualizationType").should("exist").should("contain", "Chart");
.should("exist") cy.getByTestId("VisualizationName").should("exist").should("have.value", "Chart");
.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") cy.getByTestId("VisualizationType").should("exist").should("contain", "Table");
.should("exist") cy.getByTestId("VisualizationName").should("exist").should("have.value", "Table");
.should("contain", "Table");
cy.getByTestId("VisualizationName")
.should("exist")
.should("have.value", "Table");
}); });
it("creates visualization with custom name", () => { it("creates visualization with custom name", () => {
@@ -44,15 +35,9 @@ describe("Edit visualization dialog", () => {
VisualizationType.TABLE VisualizationType.TABLE
`); `);
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear()
.type(visualizationName);
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });

View File

@@ -25,6 +25,7 @@ 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();
}); });
}); });
@@ -59,9 +60,7 @@ 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") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -81,9 +80,7 @@ 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") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -24,6 +24,7 @@ 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();
}); });
}); });
@@ -51,9 +52,7 @@ 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") cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
.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
@@ -85,9 +84,7 @@ 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") cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
.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,9 +19,7 @@ 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") cy.getByTestId("VisualizationName").clear().type(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")
@@ -29,36 +27,30 @@ function createPivotThroughUI(visualizationName, options = {}) {
.find(".pvtAxisContainer, .pvtRenderer, .pvtVals") .find(".pvtAxisContainer, .pvtRenderer, .pvtVals")
.should("be.not.visible"); .should("be.not.visible");
} }
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.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 }) cy.createQuery({ name: "Pivot Visualization", query: SQL }).its("id").as("queryId");
.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") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.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";
@@ -76,7 +68,7 @@ describe("Pivot", () => {
.should("be.not.visible"); .should("be.not.visible");
}); });
it("updates the visualization when results change", function() { it("updates the visualization when results change", function () {
const options = { const options = {
aggregatorName: "Count", aggregatorName: "Count",
data: [], // force it to have a data object, although it shouldn't data: [], // force it to have a data object, although it shouldn't
@@ -86,8 +78,9 @@ 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
@@ -104,16 +97,14 @@ describe("Pivot", () => {
cy.wait(200); cy.wait(200);
cy.getByTestId("SaveButton").click(); cy.getByTestId("SaveButton").click();
cy.getByTestId("ExecuteButton") cy.getByTestId("ExecuteButton").should("be.enabled").click();
.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");
}); });
}); });
it("takes a snapshot with different configured Pivots", function() { it("takes a snapshot with different configured Pivots", function () {
const options = { const options = {
aggregatorName: "Sum", aggregatorName: "Sum",
controls: { enabled: true }, controls: { enabled: true },
@@ -142,19 +133,20 @@ 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(pivot => () => pivotTables.map(
cy (pivot) => () =>
.createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options) cy
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position })) .createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options)
.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,6 +25,7 @@ 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");
@@ -34,37 +35,21 @@ describe("Sankey and Sunburst", () => {
it("creates Sunburst", () => { it("creates Sunburst", () => {
const visualizationName = "Sunburst"; const visualizationName = "Sunburst";
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear() cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
it("creates Sankey", () => { it("creates Sankey", () => {
const visualizationName = "Sankey"; const visualizationName = "Sankey";
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear() cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });
@@ -92,21 +77,22 @@ 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(sunburst => () => STAGES_WIDGETS.map(
cy (sunburst) => () =>
.createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query }) cy
.then(queryData => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {})) .createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query })
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position })) .then((queryData) => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {}))
.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"));
}); });
@@ -117,21 +103,22 @@ 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(sankey => () => STAGES_WIDGETS.map(
cy (sankey) => () =>
.createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query }) cy
.then(queryData => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {})) .createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query })
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position })) .then((queryData) => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {}))
.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,6 +64,7 @@ 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);
@@ -80,9 +81,7 @@ 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") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 11);
.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] });
}); });
@@ -99,9 +98,7 @@ 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") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 5);
.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] });
}); });
@@ -125,9 +122,7 @@ 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") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 2);
.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: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
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,6 +1,6 @@
{ {
"name": "redash-client", "name": "redash-client",
"version": "25.08.0-dev", "version": "25.11.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.4.12", "ace-builds": "^1.43.3",
"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": "^9.1.1", "react-ace": "^14.0.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,6 +100,7 @@
"@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,20 +140,23 @@
"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": "3.3.2",
"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", "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": "^4.46.0", "webpack": "^5.101.3",
"webpack-build-notifier": "^2.3.0", "webpack-build-notifier": "^3.0.1",
"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": "^2.0.4" "webpack-manifest-plugin": "^5.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "^2.3.2" "fsevents": "^2.3.2"

374
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,17 @@
[project] [project]
name = "redash"
version = "25.11.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']
@@ -10,17 +22,6 @@ force-exclude = '''
)/ )/
''' '''
[tool.poetry]
name = "redash"
version = "25.08.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"
@@ -103,6 +104,7 @@ 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"

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.08.0-dev" __version__ = "25.11.0-dev"
if os.environ.get("REMOTE_DEBUG"): if os.environ.get("REMOTE_DEBUG"):

View File

@@ -278,6 +278,7 @@ 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,

View File

@@ -26,6 +26,8 @@ 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,6 +44,8 @@ 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)
@@ -239,6 +241,8 @@ 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"})
@@ -362,6 +366,8 @@ 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,6 +2,7 @@ import calendar
import datetime import datetime
import logging import logging
import numbers import numbers
import re
import time import time
import pytz import pytz
@@ -228,7 +229,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
def _sort_schema(self, schema): def _sort_schema(self, schema):
return [ return [
{"name": i["name"], "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)} {**i, "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"])
] ]
@@ -644,6 +645,43 @@ 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,
@@ -664,12 +702,7 @@ 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
pattern = "%{}%".format(term) return cls._do_multi_byte_search(all_queries, term, limit)
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)
@@ -678,13 +711,7 @@ 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
pattern = "%{}%".format(term) return cls._do_multi_byte_search(cls.by_user(user), term, limit)
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)
@@ -726,6 +753,23 @@ 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.
@@ -1145,15 +1189,19 @@ 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 base_query.join( return (
( base_query.distinct(cls.lowercase_name, Dashboard.created_at, Dashboard.slug, Favorite.created_at)
Favorite, .join(
and_( (
Favorite.object_type == "Dashboard", Favorite,
Favorite.object_id == Dashboard.id, and_(
), Favorite.object_type == "Dashboard",
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

@@ -156,6 +156,11 @@ 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)
@@ -215,11 +220,12 @@ 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._get_location(), location=self.current_job_location,
job_id=self.current_job_id, job_id=self.current_job_id,
start_index=current_row, start_index=current_row,
) )
@@ -236,13 +242,11 @@ class BigQuery(BaseSQLQueryRunner):
query_result_request = { query_result_request = {
"projectId": project_id, "projectId": project_id,
"jobId": query_reply["jobReference"]["jobId"], "jobId": self.current_job_id,
"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 = [
@@ -304,32 +308,70 @@ 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 SELECT table_schema, table_name, field_path, data_type, description
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"] location = dataset["location"]
if self._get_location() and location != self._get_location(): if self._get_location() and location != self._get_location():
logger.debug("dataset location is different: %s", location) logger.debug("dataset location is different: %s", location)
continue continue
query = query_base.format(dataset_id=dataset_id)
queries.append(query)
query = "\nUNION ALL\n".join(queries) if location not in location_dataset_ids:
results, error = self.run_query(query, None) location_dataset_ids[location] = []
if error is not None: location_dataset_ids[location].append(dataset_id)
self._handle_run_query_error(error)
for row in results["rows"]: for location, datasets in location_dataset_ids.items():
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"]) queries = []
if table_name not in schema: for dataset_id in datasets:
schema[table_name] = {"name": table_name, "columns": []} query = query_base.format(dataset_id=dataset_id)
schema[table_name]["columns"].append({"name": row["field_path"], "type": row["data_type"]}) queries.append(query)
query = "\nUNION ALL\n".join(queries)
results, error = self.run_query(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": []}
schema[table_name]["columns"].append(
{
"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())
@@ -363,7 +405,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._get_location(), location=self.current_job_location,
).execute() ).execute()
raise raise

View File

@@ -0,0 +1,174 @@
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,9 +34,13 @@ 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"]
for k, v in issue["fields"].items(): # # Handle API v3 response format: key field may be missing, use id as fallback
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)
@@ -98,7 +102,9 @@ def parse_issues(data, field_mapping):
def parse_count(data): def parse_count(data):
results = ResultSet() results = ResultSet()
results.add_row({"count": data["total"]}) # API v3 may not return 'total' field, fallback to counting issues
count = data.get("total", len(data.get("issues", [])))
results.add_row({"count": count})
return results return results
@@ -160,18 +166,26 @@ class JiraJQL(BaseHTTPQueryRunner):
self.syntax = "json" self.syntax = "json"
def run_query(self, query, user): def run_query(self, query, user):
jql_url = "{}/rest/api/2/search".format(self.configuration["url"]) # Updated to API v3 endpoint, fix double slash issue
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
@@ -182,17 +196,15 @@ 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"]
while data["total"] > index: # API v3 uses token-based pagination instead of startAt/total
query["startAt"] = index while not data.get("isLast", True) and "nextPageToken" in data:
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

@@ -150,7 +150,9 @@ 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');
""" """
@@ -169,7 +171,38 @@ 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(row["column_name"]) schema[table_name]["columns"].append(
{
"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

@@ -205,24 +205,15 @@ class PostgreSQL(BaseSQLQueryRunner):
def _get_tables(self, schema): def _get_tables(self, schema):
""" """
relkind constants per https://www.postgresql.org/docs/10/static/catalog-pg-class.html relkind constants from https://www.postgresql.org/docs/current/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
@@ -231,7 +222,7 @@ 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 IN ('m', 'f', 'p') WHERE c.relkind = 'm'
AND has_table_privilege(s.nspname || '.' || 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')
@@ -243,6 +234,8 @@ 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(table_schema || '.' || table_name, 'select')
AND has_schema_privilege(table_schema, 'usage')
""" """
self._get_definitions(schema, query) self._get_definitions(schema, query)

View File

@@ -1,11 +1,14 @@
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,
@@ -43,6 +46,8 @@ 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"},
@@ -57,13 +62,15 @@ 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", "password", "account", "database", "warehouse"], "required": ["user", "account", "database", "warehouse"],
"secret": ["password"], "secret": ["password", "private_key_File", "private_key_pwd"],
"extra_options": [ "extra_options": [
"host", "host",
], ],
@@ -88,7 +95,7 @@ class Snowflake(BaseSQLQueryRunner):
if region == "us-west": if region == "us-west":
region = None region = None
if self.configuration.__contains__("host"): if self.configuration.get("host"):
host = self.configuration.get("host") host = self.configuration.get("host")
else: else:
if region: if region:
@@ -96,14 +103,29 @@ class Snowflake(BaseSQLQueryRunner):
else: else:
host = "{}.snowflakecomputing.com".format(account) host = "{}.snowflakecomputing.com".format(account)
connection = snowflake.connector.connect( params = {
user=self.configuration["user"], "user": self.configuration["user"],
password=self.configuration["password"], "account": account,
account=account, "region": region,
region=region, "host": host,
host=host, "application": "Redash/{} (Snowflake)".format(__version__.split("-")[0]),
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

@@ -82,9 +82,19 @@ 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):
favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list) queries = list(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:
query["is_favorite"] = query["id"] in favorite_ids 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
@@ -263,9 +273,19 @@ 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):
favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list) dashboards = list(self.object_or_list)
for obj in result: favorites = models.Favorite.query.filter(
obj["is_favorite"] = obj["id"] in favorite_ids models.Favorite.object_id.in_([o.id for o in dashboards]),
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

@@ -348,6 +348,7 @@ 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

@@ -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, "
"Job ID: job-id, Query Hash: query-hash, " "Query Hash: query-hash, "
"Scheduled: False */ SELECT a FROM tbl" "Scheduled: False */ SELECT a FROM tbl"
) )

View File

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

@@ -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": "^2.35.2", "@types/plotly.js": "^3.0.3",
"@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",
@@ -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": "2.35.3", "plotly.js": "3.1.0",
"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

@@ -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.plot(element, ...) // To plot your graph call Plotly.newPlot(element, ...)
// Plotly examples and docs: https://plot.ly/javascript/ // Plotly examples and docs: https://plot.ly/javascript/
`); `);

View File

@@ -336,7 +336,7 @@ export default function GeneralSettings({ options, data, onOptionsChange }: any)
</Section> </Section>
)} )}
{!includes(["custom", "heatmap", "bubble", "scatter"], options.globalSeriesType) && ( {!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

@@ -48,7 +48,6 @@
"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"],
@@ -64,7 +63,6 @@
}, },
{ {
"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,7 +48,6 @@
"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"],
@@ -64,7 +63,6 @@
}, },
{ {
"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

@@ -8,10 +8,30 @@ import updateAxes from "./updateAxes";
import updateChartSize from "./updateChartSize"; import updateChartSize from "./updateChartSize";
import { prepareCustomChartData, createCustomChartRenderer } from "./customChartUtils"; import { prepareCustomChartData, createCustomChartRenderer } from "./customChartUtils";
// @ts-expect-error ts-migrate(2339) FIXME: Property 'setPlotConfig' does not exist on type 't... Remove this comment to see the full error message const rangeSliderIcon = {
'width': 400,
'height': 400,
'path': 'M50 180h300a20 20 0 0 1 0 40H50a20 20 0 0 1 0-40z M160 200a40 40 0 1 0 -80 0a40 40 0 1 0 80 0 M320 200a40 40 0 1 0 -80 0a40 40 0 1 0 80 0',
};
Plotly.setPlotConfig({ Plotly.setPlotConfig({
modeBarButtonsToRemove: ["sendDataToCloud"], modeBarButtonsToRemove: ["sendDataToCloud"],
modeBarButtonsToAdd: ["togglespikelines", "v1hovermode"], modeBarButtonsToAdd: ["togglespikelines", "v1hovermode",
{
name: 'toggleRangeslider',
title: 'Toggle rangeslider',
icon: rangeSliderIcon,
click: function(gd: any) {
if(gd?.layout?.xaxis) {
let newRangeslider: any = {};
if (gd.layout.xaxis?.rangeslider) {
newRangeslider = null;
}
(Plotly.relayout as any)(gd, 'xaxis.rangeslider', newRangeslider);
}
}
},
],
locale: window.navigator.language, locale: window.navigator.language,
}); });

View File

@@ -26,7 +26,9 @@ function getHoverInfoPattern(options: any) {
function prepareBarSeries(series: any, options: any, additionalOptions: any) { function prepareBarSeries(series: any, options: any, additionalOptions: any) {
series.type = "bar"; series.type = "bar";
series.offsetgroup = toString(additionalOptions.index); if (!options.series.stacking) {
series.offsetgroup = toString(additionalOptions.index);
}
if (options.showDataLabels) { if (options.showDataLabels) {
series.textposition = "inside"; series.textposition = "inside";
} else { } else {
@@ -94,7 +96,10 @@ function prepareSeries(series: any, options: any, numSeries: any, additionalOpti
// For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size; // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size;
// for other types `y` is always number // for other types `y` is always number
const cleanYValue = includes(["bubble", "scatter"], seriesOptions.type) const cleanYValue = includes(["bubble", "scatter"], seriesOptions.type)
? normalizeValue ? (v: any, axixType: any) => {
v = normalizeValue(v, axixType);
return includes(["scatter"], seriesOptions.type) && options.missingValuesAsZero && isNil(v) ? 0.0 : v;
}
: (v: any) => { : (v: any) => {
v = cleanNumber(v); v = cleanNumber(v);
return options.missingValuesAsZero && isNil(v) ? 0.0 : v; return options.missingValuesAsZero && isNil(v) ? 0.0 : v;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ export default function initTextColumn(column: any) {
function TextColumn({ row }: any) { function TextColumn({ row }: any) {
// eslint-disable-line react/prop-types // eslint-disable-line react/prop-types
const { text } = prepareData(row); const { text } = prepareData(row);
return column.allowHTML ? <HtmlContent>{text}</HtmlContent> : text; return (column.allowHTML && typeof text === 'string') ? <HtmlContent>{text}</HtmlContent> : text;
} }
TextColumn.prepareData = prepareData; TextColumn.prepareData = prepareData;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1776,6 +1776,11 @@
parse-rect "^1.2.0" parse-rect "^1.2.0"
pick-by-alias "^1.2.0" pick-by-alias "^1.2.0"
"@plotly/regl@^2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@plotly/regl/-/regl-2.1.2.tgz#fd31e3e820ed8824d59a67ab5e766bb101b810b6"
integrity sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==
"@ts-morph/bootstrap@^0.16.0": "@ts-morph/bootstrap@^0.16.0":
version "0.16.0" version "0.16.0"
resolved "https://registry.yarnpkg.com/@ts-morph/bootstrap/-/bootstrap-0.16.0.tgz#c97034175a8fc2b7d3f575526d819877f7ed2d83" resolved "https://registry.yarnpkg.com/@ts-morph/bootstrap/-/bootstrap-0.16.0.tgz#c97034175a8fc2b7d3f575526d819877f7ed2d83"
@@ -2229,10 +2234,10 @@
resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404" resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404"
integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA== integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==
"@types/plotly.js@^2.35.2": "@types/plotly.js@^3.0.3":
version "2.35.2" version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/plotly.js/-/plotly.js-2.35.2.tgz#c771a7bf56b398730f8c799879895de4294289c0" resolved "https://registry.yarnpkg.com/@types/plotly.js/-/plotly.js-3.0.3.tgz#ed72b67ce65adc22b2bc75da845e973335ea7234"
integrity sha512-tn0Kp7F6VWiu96jknCvR/PcdIGIATeIK+Z5WXH3bEvG6CRwUNfhy34yBhfPYmTea7mMQxXvTZKGMm6/Y4wxESg== integrity sha512-9CENH8hh2diOML3o4lEd4H0nwQ4uECEE9mZQc+zriGEdd0zK8ru75t7qFhaMQmiWFFPGWqI4FpodBZFTmWpdbQ==
"@types/prop-types@*": "@types/prop-types@*":
version "15.7.3" version "15.7.3"
@@ -3299,6 +3304,11 @@ color-name@1.1.3, color-name@^1.0.0:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
color-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.0.0.tgz#03ff6b1b5aec9bb3cf1ed82400c2790dfcd01d2d"
integrity sha512-SbtvAMWvASO5TE2QP07jHBMXKafgdZz8Vrsrn96fiL+O92/FN/PLARzUW5sKt013fjAprK2d2iCn2hk2Xb5oow==
color-name@~1.1.4: color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
@@ -3329,7 +3339,22 @@ color-parse@^1.3.8:
defined "^1.0.0" defined "^1.0.0"
is-plain-obj "^1.1.0" is-plain-obj "^1.1.0"
color-rgba@2.1.1, color-rgba@^2.1.1: color-parse@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/color-parse/-/color-parse-2.0.2.tgz#37b46930424924060988edf25b24e6ffb4a1dc3f"
integrity sha512-eCtOz5w5ttWIUcaKLiktF+DxZO1R9KLNY/xhbV6CkhM7sR3GhVghmt6X6yOnzeaM24po+Z9/S1apbXMwA3Iepw==
dependencies:
color-name "^2.0.0"
color-rgba@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-3.0.0.tgz#77090bdcdb2951c1735e20099ddd50401675375b"
integrity sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==
dependencies:
color-parse "^2.0.0"
color-space "^2.0.0"
color-rgba@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-2.1.1.tgz#4633b83817c7406c90b3d7bf4d1acfa48dde5c83" resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-2.1.1.tgz#4633b83817c7406c90b3d7bf4d1acfa48dde5c83"
integrity sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw== integrity sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==
@@ -3346,6 +3371,11 @@ color-space@^1.14.6:
hsluv "^0.0.3" hsluv "^0.0.3"
mumath "^3.3.4" mumath "^3.3.4"
color-space@^2.0.0:
version "2.3.2"
resolved "https://registry.yarnpkg.com/color-space/-/color-space-2.3.2.tgz#d8c72bab09ef26b98abebc58bc1586ce3073033d"
integrity sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==
colorette@^2.0.14: colorette@^2.0.14:
version "2.0.20" version "2.0.20"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
@@ -3532,20 +3562,6 @@ css-loader@^3.5.2:
schema-utils "^2.6.5" schema-utils "^2.6.5"
semver "^6.3.0" semver "^6.3.0"
css-loader@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-7.1.2.tgz#64671541c6efe06b0e22e750503106bdd86880f8"
integrity sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==
dependencies:
icss-utils "^5.1.0"
postcss "^8.4.33"
postcss-modules-extract-imports "^3.1.0"
postcss-modules-local-by-default "^4.0.5"
postcss-modules-scope "^3.2.0"
postcss-modules-values "^4.0.0"
postcss-value-parser "^4.2.0"
semver "^7.5.4"
css-select@~1.2.0: css-select@~1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
@@ -5337,11 +5353,6 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
dependencies: dependencies:
postcss "^7.0.14" postcss "^7.0.14"
icss-utils@^5.0.0, icss-utils@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
ieee754@^1.1.12: ieee754@^1.1.12:
version "1.1.13" version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
@@ -6693,7 +6704,7 @@ map-visit@^1.0.0:
dependencies: dependencies:
object-visit "^1.0.0" object-visit "^1.0.0"
maplibre-gl@^4.5.2: maplibre-gl@^4.7.1:
version "4.7.1" version "4.7.1"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-4.7.1.tgz#06a524438ee2aafbe8bcd91002a4e01468ea5486" resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-4.7.1.tgz#06a524438ee2aafbe8bcd91002a4e01468ea5486"
integrity sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA== integrity sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==
@@ -6896,11 +6907,6 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb"
integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
nanoid@^3.3.8:
version "3.3.8"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
nanomatch@^1.2.9: nanomatch@^1.2.9:
version "1.2.13" version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -7447,11 +7453,6 @@ picocolors@^1.0.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1"
integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
@@ -7505,15 +7506,16 @@ pkg-up@^3.1.0:
dependencies: dependencies:
find-up "^3.0.0" find-up "^3.0.0"
plotly.js@2.35.3: plotly.js@3.1.0:
version "2.35.3" version "3.1.0"
resolved "https://registry.yarnpkg.com/plotly.js/-/plotly.js-2.35.3.tgz#6a7787d63b4d334948c281aa9c8df7fb941b425e" resolved "https://registry.yarnpkg.com/plotly.js/-/plotly.js-3.1.0.tgz#a095a37d0f1b04bb0e9686853df54a4e6437af2e"
integrity sha512-7RaC6FxmCUhpD6H4MpD+QLUu3hCn76I11rotRefrh3m1iDvWqGnVqVk9dSaKmRAhFD3vsNsYea0OxnR1rc2IzQ== integrity sha512-vx+CyzApL9tquFpwoPHOGSIWDbFPsA4om/tXZcnsygGUejXideDF9R5VwkltEIDG7Xuof45quVPyz1otv6Aqjw==
dependencies: dependencies:
"@plotly/d3" "3.8.2" "@plotly/d3" "3.8.2"
"@plotly/d3-sankey" "0.7.2" "@plotly/d3-sankey" "0.7.2"
"@plotly/d3-sankey-circular" "0.33.1" "@plotly/d3-sankey-circular" "0.33.1"
"@plotly/mapbox-gl" "1.13.4" "@plotly/mapbox-gl" "1.13.4"
"@plotly/regl" "^2.1.2"
"@turf/area" "^7.1.0" "@turf/area" "^7.1.0"
"@turf/bbox" "^7.1.0" "@turf/bbox" "^7.1.0"
"@turf/centroid" "^7.1.0" "@turf/centroid" "^7.1.0"
@@ -7522,9 +7524,8 @@ plotly.js@2.35.3:
color-alpha "1.0.4" color-alpha "1.0.4"
color-normalize "1.5.0" color-normalize "1.5.0"
color-parse "2.0.0" color-parse "2.0.0"
color-rgba "2.1.1" color-rgba "3.0.0"
country-regex "^1.1.0" country-regex "^1.1.0"
css-loader "^7.1.2"
d3-force "^1.2.1" d3-force "^1.2.1"
d3-format "^1.4.5" d3-format "^1.4.5"
d3-geo "^1.12.1" d3-geo "^1.12.1"
@@ -7539,7 +7540,7 @@ plotly.js@2.35.3:
has-hover "^1.0.1" has-hover "^1.0.1"
has-passive-events "^1.0.0" has-passive-events "^1.0.0"
is-mobile "^4.0.0" is-mobile "^4.0.0"
maplibre-gl "^4.5.2" maplibre-gl "^4.7.1"
mouse-change "^1.4.0" mouse-change "^1.4.0"
mouse-event-offset "^3.0.2" mouse-event-offset "^3.0.2"
mouse-wheel "^1.2.0" mouse-wheel "^1.2.0"
@@ -7548,20 +7549,18 @@ plotly.js@2.35.3:
point-in-polygon "^1.1.0" point-in-polygon "^1.1.0"
polybooljs "^1.2.2" polybooljs "^1.2.2"
probe-image-size "^7.2.3" probe-image-size "^7.2.3"
regl "npm:@plotly/regl@^2.1.2"
regl-error2d "^2.0.12" regl-error2d "^2.0.12"
regl-line2d "^3.1.3" regl-line2d "^3.1.3"
regl-scatter2d "^3.3.1" regl-scatter2d "^3.3.1"
regl-splom "^1.0.14" regl-splom "^1.0.14"
strongly-connected-components "^1.0.1" strongly-connected-components "^1.0.1"
style-loader "^4.0.0"
superscript-text "^1.0.0" superscript-text "^1.0.0"
svg-path-sdf "^1.1.3" svg-path-sdf "^1.1.3"
tinycolor2 "^1.4.2" tinycolor2 "^1.4.2"
to-px "1.0.1" to-px "1.0.1"
topojson-client "^3.1.0" topojson-client "^3.1.0"
webgl-context "^2.2.0" webgl-context "^2.2.0"
world-calendars "^1.0.3" world-calendars "^1.0.4"
pn@^1.1.0: pn@^1.1.0:
version "1.1.0" version "1.1.0"
@@ -7590,11 +7589,6 @@ postcss-modules-extract-imports@^2.0.0:
dependencies: dependencies:
postcss "^7.0.5" postcss "^7.0.5"
postcss-modules-extract-imports@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002"
integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==
postcss-modules-local-by-default@^3.0.2: postcss-modules-local-by-default@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915"
@@ -7605,15 +7599,6 @@ postcss-modules-local-by-default@^3.0.2:
postcss-selector-parser "^6.0.2" postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.0.0" postcss-value-parser "^4.0.0"
postcss-modules-local-by-default@^4.0.5:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz#d150f43837831dae25e4085596e84f6f5d6ec368"
integrity sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==
dependencies:
icss-utils "^5.0.0"
postcss-selector-parser "^7.0.0"
postcss-value-parser "^4.1.0"
postcss-modules-scope@^2.2.0: postcss-modules-scope@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee"
@@ -7622,13 +7607,6 @@ postcss-modules-scope@^2.2.0:
postcss "^7.0.6" postcss "^7.0.6"
postcss-selector-parser "^6.0.0" postcss-selector-parser "^6.0.0"
postcss-modules-scope@^3.2.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c"
integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==
dependencies:
postcss-selector-parser "^7.0.0"
postcss-modules-values@^3.0.0: postcss-modules-values@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10"
@@ -7637,13 +7615,6 @@ postcss-modules-values@^3.0.0:
icss-utils "^4.0.0" icss-utils "^4.0.0"
postcss "^7.0.6" postcss "^7.0.6"
postcss-modules-values@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c"
integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==
dependencies:
icss-utils "^5.0.0"
postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c"
@@ -7653,14 +7624,6 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
indexes-of "^1.0.1" indexes-of "^1.0.1"
uniq "^1.0.1" uniq "^1.0.1"
postcss-selector-parser@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262"
integrity sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-value-parser@^3.2.3: postcss-value-parser@^3.2.3:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
@@ -7671,11 +7634,6 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.3:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d"
integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==
postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^6.0.22, postcss@^6.0.23: postcss@^6.0.22, postcss@^6.0.23:
version "6.0.23" version "6.0.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
@@ -7694,15 +7652,6 @@ postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.27, postcss@^7.0.5, postcss@^7.0.
source-map "^0.6.1" source-map "^0.6.1"
supports-color "^6.1.0" supports-color "^6.1.0"
postcss@^8.4.33:
version "8.5.3"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb"
integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==
dependencies:
nanoid "^3.3.8"
picocolors "^1.1.1"
source-map-js "^1.2.1"
potpack@^1.0.1: potpack@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/potpack/-/potpack-1.0.1.tgz#d1b1afd89e4c8f7762865ec30bd112ab767e2ebf" resolved "https://registry.yarnpkg.com/potpack/-/potpack-1.0.1.tgz#d1b1afd89e4c8f7762865ec30bd112ab767e2ebf"
@@ -8231,11 +8180,6 @@ regl@^2.0.0:
resolved "https://registry.yarnpkg.com/regl/-/regl-2.1.1.tgz#fb3eecbc684031ec6172f68aaab2cbe9c3aa3148" resolved "https://registry.yarnpkg.com/regl/-/regl-2.1.1.tgz#fb3eecbc684031ec6172f68aaab2cbe9c3aa3148"
integrity sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g== integrity sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==
"regl@npm:@plotly/regl@^2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@plotly/regl/-/regl-2.1.2.tgz#fd31e3e820ed8824d59a67ab5e766bb101b810b6"
integrity sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==
remove-trailing-separator@^1.0.1: remove-trailing-separator@^1.0.1:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@@ -8559,11 +8503,6 @@ semver@^7.2.1:
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^7.5.4:
version "7.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f"
integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==
serialize-javascript@^6.0.1: serialize-javascript@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c"
@@ -8705,11 +8644,6 @@ sortablejs@^1.6.1:
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.10.2.tgz#6e40364d913f98b85a14f6678f92b5c1221f5290"
integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A== integrity sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map-resolve@^0.5.0: source-map-resolve@^0.5.0:
version "0.5.3" version "0.5.3"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
@@ -9000,11 +8934,6 @@ style-loader@^3.3.3:
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff"
integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw== integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==
style-loader@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-4.0.0.tgz#0ea96e468f43c69600011e0589cb05c44f3b17a5"
integrity sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==
supercluster@^7.1.0: supercluster@^7.1.0:
version "7.1.5" version "7.1.5"
resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.1.5.tgz#65a6ce4a037a972767740614c19051b64b8be5a3" resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.1.5.tgz#65a6ce4a037a972767740614c19051b64b8be5a3"
@@ -9563,7 +9492,7 @@ use@^3.1.0:
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
@@ -9819,10 +9748,10 @@ word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
world-calendars@^1.0.3: world-calendars@^1.0.4:
version "1.0.3" version "1.0.4"
resolved "https://registry.yarnpkg.com/world-calendars/-/world-calendars-1.0.3.tgz#b25c5032ba24128ffc41d09faf4a5ec1b9c14335" resolved "https://registry.yarnpkg.com/world-calendars/-/world-calendars-1.0.4.tgz#2a12bcbd796b6c99aef2e52f281229faad8fa96c"
integrity sha1-slxQMrokEo/8QdCfr0pewbnBQzU= integrity sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==
dependencies: dependencies:
object-assign "^4.1.0" object-assign "^4.1.0"

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