mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
90 Commits
release/9.
...
system-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d965bc2653 | ||
|
|
361308cb10 | ||
|
|
673c55609a | ||
|
|
97fc91f6e1 | ||
|
|
311ec78090 | ||
|
|
338c3b43e8 | ||
|
|
b7c245f925 | ||
|
|
681b2f1abd | ||
|
|
a31196aef8 | ||
|
|
596e5bee3a | ||
|
|
84d516bfd1 | ||
|
|
2cc3bd3d54 | ||
|
|
ac652c20bf | ||
|
|
1bc6cd8f41 | ||
|
|
4c70b5ce8e | ||
|
|
de052ff02b | ||
|
|
a596d6558c | ||
|
|
fc71acdc09 | ||
|
|
b326d36ae8 | ||
|
|
378cc57d42 | ||
|
|
83c6a6bcd2 | ||
|
|
5afd0554d0 | ||
|
|
eb603f63f0 | ||
|
|
6c00f7c4e3 | ||
|
|
f56f4c4899 | ||
|
|
d3b639a68a | ||
|
|
3332b656ac | ||
|
|
24c95379ca | ||
|
|
93b4be672f | ||
|
|
f3a47a9658 | ||
|
|
7804dfd68e | ||
|
|
2dacd08bea | ||
|
|
fd76a2ecfb | ||
|
|
7f98d7b694 | ||
|
|
a1255b4144 | ||
|
|
6c349ea70a | ||
|
|
95c28c47ad | ||
|
|
48924de700 | ||
|
|
41a691328a | ||
|
|
cb97364771 | ||
|
|
d12691dc2a | ||
|
|
6f9e79c641 | ||
|
|
461f98bbfc | ||
|
|
81e7c72d48 | ||
|
|
328f0f3f0c | ||
|
|
ecb9adf903 | ||
|
|
87e09f676e | ||
|
|
6fc5c803e0 | ||
|
|
6c57aa448e | ||
|
|
878b297601 | ||
|
|
9c0450c84e | ||
|
|
74f206614f | ||
|
|
2f26cf791c | ||
|
|
c6be5758ad | ||
|
|
8341592b05 | ||
|
|
a7edbf1e8d | ||
|
|
217f41b586 | ||
|
|
a8bd07e293 | ||
|
|
332c16b130 | ||
|
|
7940d36616 | ||
|
|
68b70ed63b | ||
|
|
e0297835df | ||
|
|
004bc7a2ac | ||
|
|
efcf22079f | ||
|
|
a83cb18cc5 | ||
|
|
1ecdf7b853 | ||
|
|
90024ebc92 | ||
|
|
a37b7babbf | ||
|
|
8f4ac958b1 | ||
|
|
637d9837f4 | ||
|
|
bdd3c3e735 | ||
|
|
6fc35510d3 | ||
|
|
6f842ef94a | ||
|
|
a563900f0a | ||
|
|
ee3930c64d | ||
|
|
10bff8b3b1 | ||
|
|
a8510d1ad5 | ||
|
|
3a543a4ab2 | ||
|
|
2b1ba1ee33 | ||
|
|
4a54ad9d06 | ||
|
|
676f560830 | ||
|
|
98a5154345 | ||
|
|
4c324ddc80 | ||
|
|
05c2233782 | ||
|
|
0ac24e38a1 | ||
|
|
d036df0ca1 | ||
|
|
56df870f39 | ||
|
|
05540164e1 | ||
|
|
bdb62365b1 | ||
|
|
6a12168f40 |
@@ -108,6 +108,11 @@ jobs:
|
||||
- run:
|
||||
name: Execute Cypress tests
|
||||
command: npm run cypress run-ci
|
||||
- run:
|
||||
name: "Failure: output container logs to console"
|
||||
command: |
|
||||
docker-compose logs
|
||||
when: on_fail
|
||||
build-docker-image: *build-docker-image-job
|
||||
build-preview-docker-image: *build-docker-image-job
|
||||
workflows:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3'
|
||||
version: '2.2'
|
||||
services:
|
||||
redash:
|
||||
build: ../
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3'
|
||||
version: '2.2'
|
||||
services:
|
||||
server:
|
||||
build: ../
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
REDASH_REDIS_URL: "redis://redis:6379/0"
|
||||
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
REDASH_RATELIMIT_ENABLED: "false"
|
||||
REDASH_ENFORCE_CSRF: "true"
|
||||
scheduler:
|
||||
build: ../
|
||||
command: scheduler
|
||||
|
||||
@@ -6,8 +6,8 @@ from pathlib import Path
|
||||
from shutil import copy
|
||||
from collections import OrderedDict as odict
|
||||
|
||||
from importlib_metadata import entry_points
|
||||
from importlib_resources import contents, is_resource, path
|
||||
import importlib_metadata
|
||||
import importlib_resources
|
||||
|
||||
# Name of the subdirectory
|
||||
BUNDLE_DIRECTORY = "bundle"
|
||||
@@ -25,18 +25,6 @@ if not extensions_directory.exists():
|
||||
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path)
|
||||
|
||||
|
||||
def resource_isdir(module, resource):
|
||||
"""Whether a given resource is a directory in the given module
|
||||
|
||||
https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir
|
||||
"""
|
||||
try:
|
||||
return resource in contents(module) and not is_resource(module, resource)
|
||||
except (ImportError, TypeError):
|
||||
# module isn't a package, so can't have a subdirectory/-package
|
||||
return False
|
||||
|
||||
|
||||
def entry_point_module(entry_point):
|
||||
"""Returns the dotted module path for the given entry point"""
|
||||
return entry_point.pattern.match(entry_point.value).group("module")
|
||||
@@ -77,18 +65,28 @@ def load_bundles():
|
||||
|
||||
"""
|
||||
bundles = odict()
|
||||
for entry_point in entry_points().get("redash.bundles", []):
|
||||
for entry_point in importlib_metadata.entry_points().get("redash.bundles", []):
|
||||
logger.info('Loading Redash bundle "%s".', entry_point.name)
|
||||
module = entry_point_module(entry_point)
|
||||
# Try to get a list of bundle files
|
||||
if not resource_isdir(module, BUNDLE_DIRECTORY):
|
||||
try:
|
||||
bundle_dir = importlib_resources.files(module).joinpath(BUNDLE_DIRECTORY)
|
||||
except (ImportError, TypeError):
|
||||
# Module isn't a package, so can't have a subdirectory/-package
|
||||
logger.error(
|
||||
'Redash bundle directory "%s" could not be found.', entry_point.name
|
||||
'Redash bundle module "%s" could not be imported: "%s"',
|
||||
entry_point.name,
|
||||
module,
|
||||
)
|
||||
continue
|
||||
with path(module, BUNDLE_DIRECTORY) as bundle_dir:
|
||||
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
||||
|
||||
if not bundle_dir.is_dir():
|
||||
logger.error(
|
||||
'Redash bundle directory "%s" could not be found or is not a directory: "%s"',
|
||||
entry_point.name,
|
||||
bundle_dir,
|
||||
)
|
||||
continue
|
||||
bundles[entry_point.name] = list(bundle_dir.rglob("*"))
|
||||
return bundles
|
||||
|
||||
|
||||
|
||||
@@ -126,4 +126,3 @@ case "$1" in
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", {
|
||||
"exclude": [
|
||||
"@babel/plugin-transform-async-to-generator",
|
||||
"@babel/plugin-transform-arrow-functions"
|
||||
],
|
||||
"useBuiltIns": "usage"
|
||||
}],
|
||||
"@babel/preset-react"
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"],
|
||||
"corejs": "2",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
],
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"@babel/plugin-transform-object-assign",
|
||||
["babel-plugin-transform-builtin-extend", {
|
||||
"globals": ["Error"]
|
||||
}]
|
||||
[
|
||||
"babel-plugin-transform-builtin-extend",
|
||||
{
|
||||
"globals": ["Error"]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["react-app", "plugin:compat/recommended", "prettier"],
|
||||
plugins: ["jest", "compat", "no-only-tests"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: [
|
||||
"react-app",
|
||||
"plugin:compat/recommended",
|
||||
"prettier",
|
||||
// Remove any typescript-eslint rules that would conflict with prettier
|
||||
"prettier/@typescript-eslint",
|
||||
],
|
||||
plugins: ["jest", "compat", "no-only-tests", "@typescript-eslint"],
|
||||
settings: {
|
||||
"import/resolver": "webpack"
|
||||
"import/resolver": "webpack",
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
// allow debugger during development
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
}
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// Only run typescript-eslint on TS files
|
||||
files: ["*.ts", "*.tsx", ".*.ts", ".*.tsx"],
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
// Do not require functions (especially react components) to have explicit returns
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
// Do not require to type every import from a JS file to speed up development
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
// Do not complain about useless contructors in declaration files
|
||||
"no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
@import "~antd/lib/pagination/style/index";
|
||||
@import "~antd/lib/table/style/index";
|
||||
@import "~antd/lib/popover/style/index";
|
||||
@import "~antd/lib/icon/style/index";
|
||||
@import "~antd/lib/tag/style/index";
|
||||
@import "~antd/lib/grid/style/index";
|
||||
@import "~antd/lib/switch/style/index";
|
||||
@@ -31,6 +30,7 @@
|
||||
@import "~antd/lib/badge/style/index";
|
||||
@import "~antd/lib/card/style/index";
|
||||
@import "~antd/lib/spin/style/index";
|
||||
@import "~antd/lib/skeleton/style/index";
|
||||
@import "~antd/lib/tabs/style/index";
|
||||
@import "~antd/lib/notification/style/index";
|
||||
@import "~antd/lib/collapse/style/index";
|
||||
@@ -401,3 +401,14 @@
|
||||
.@{checkbox-prefix-cls} + span {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
// make sure Multiple select has room for icons
|
||||
.@{select-prefix-cls}-multiple {
|
||||
&.@{select-prefix-cls}-show-arrow,
|
||||
&.@{select-prefix-cls}-show-search,
|
||||
&.@{select-prefix-cls}-loading {
|
||||
.@{select-prefix-cls}-selector {
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.ant-form-item-explain {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.alert-last-triggered {
|
||||
color: @headings-color;
|
||||
}
|
||||
|
||||
@@ -32,17 +32,6 @@ body {
|
||||
#application-root {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
&.headless {
|
||||
#application-root {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.app-header-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#application-root {
|
||||
@@ -89,46 +78,16 @@ strong {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed width layout for specific pages
|
||||
@media (min-width: 768px) {
|
||||
.settings-screen,
|
||||
.home-page,
|
||||
.page-dashboard-list,
|
||||
.page-queries-list,
|
||||
.page-alerts-list,
|
||||
.alert-page,
|
||||
.fixed-container {
|
||||
.container {
|
||||
width: 750px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.settings-screen,
|
||||
.home-page,
|
||||
.page-dashboard-list,
|
||||
.page-queries-list,
|
||||
.page-alerts-list,
|
||||
.alert-page,
|
||||
.fixed-container {
|
||||
.container {
|
||||
width: 970px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.settings-screen,
|
||||
.home-page,
|
||||
.page-dashboard-list,
|
||||
.page-queries-list,
|
||||
.page-alerts-list,
|
||||
.alert-page,
|
||||
.fixed-container {
|
||||
.container {
|
||||
width: 1170px;
|
||||
}
|
||||
.settings-screen,
|
||||
.home-page,
|
||||
.page-dashboard-list,
|
||||
.page-queries-list,
|
||||
.page-alerts-list,
|
||||
.alert-page,
|
||||
.admin-page-layout {
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +214,6 @@ text.slicetext {
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-wrapper,
|
||||
.page-header--new {
|
||||
h3 {
|
||||
margin: 0.2em 0;
|
||||
|
||||
@@ -6,6 +6,7 @@ div.table-name {
|
||||
padding: 2px 22px 2px 10px;
|
||||
border-radius: @redash-radius;
|
||||
position: relative;
|
||||
height: 22px;
|
||||
|
||||
.copy-to-editor {
|
||||
display: none;
|
||||
@@ -27,13 +28,19 @@ div.table-name {
|
||||
}
|
||||
|
||||
.schema-browser {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.schema-loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.collapse.in {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -57,6 +64,14 @@ div.table-name {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
height: 18px;
|
||||
|
||||
.column-type {
|
||||
color: fade(@text-color, 80%);
|
||||
font-size: 10px;
|
||||
margin-left: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.copy-to-editor {
|
||||
display: none;
|
||||
|
||||
@@ -1,149 +1,153 @@
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
|
||||
th.sortable-column {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
|
||||
th.sortable-column {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:not(.table-striped) > thead > tr > th {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
[class*="bg-"] {
|
||||
& > tr > th {
|
||||
color: #fff;
|
||||
border-bottom: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&:not(.table-striped) > thead > tr > th {
|
||||
background-color: #FAFAFA;
|
||||
|
||||
& + tbody > tr:first-child > td {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
[class*="bg-"] {
|
||||
& > tr > th {
|
||||
color: #fff;
|
||||
border-bottom: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
& + tbody > tr:first-child > td {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
border-width: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
& > thead > tr,
|
||||
& > tbody > tr,
|
||||
& > tfoot > tr {
|
||||
|
||||
& > th, & > td {
|
||||
|
||||
&:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
tbody > tr:last-child > td {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
border-width: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
& > thead > tr,
|
||||
& > tbody > tr,
|
||||
& > tfoot > tr {
|
||||
& > th,
|
||||
& > td {
|
||||
&:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody > tr:last-child > td {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-bordered {
|
||||
border: 0;
|
||||
|
||||
& > tbody > tr {
|
||||
& > td, & > th {
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
border: 0;
|
||||
|
||||
& > tbody > tr {
|
||||
& > td,
|
||||
& > th {
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
border-left: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > thead > tr > th {
|
||||
border-left: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-vmiddle {
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tile .table {
|
||||
|
||||
& > thead:not([class*="bg-"]) > tr > th {
|
||||
border-top: 1px solid @table-border-color;
|
||||
|
||||
}
|
||||
.tile .table {
|
||||
& > thead:not([class*="bg-"]) > tr > th {
|
||||
border-top: 1px solid @table-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover {
|
||||
background-color: #f4f4f4;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.table-data {
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite, .btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
thead > tr > th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody > tr > td {
|
||||
padding-top: 5px !important;
|
||||
}
|
||||
|
||||
.btn-favourite,
|
||||
.btn-archive {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-main-title {
|
||||
font-weight: 500;
|
||||
line-height: 1.7 !important;
|
||||
font-weight: 500;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
.btn-favourite {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @yellow-darker;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fa-star {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
color: #d4d4d4;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @yellow-darker;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fa-star {
|
||||
color: @yellow-darker;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-archive {
|
||||
color: #d4d4d4;
|
||||
transition: all .25s ease-in-out;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
.fa-archive {
|
||||
color: @gray-light;
|
||||
}
|
||||
color: #d4d4d4;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: @gray-light;
|
||||
}
|
||||
|
||||
.fa-archive {
|
||||
color: @gray-light;
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
text-transform: none;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.table-data .label-tag {
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
display: inline-block;
|
||||
max-width: 135px;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
/** Load Vendors Dependencies **/
|
||||
@import "~font-awesome/less/font-awesome";
|
||||
@import "~material-design-iconic-font/dist/css/material-design-iconic-font.css";
|
||||
@import "~pace-progress/themes/blue/pace-theme-minimal.css";
|
||||
|
||||
@import "inc/variables";
|
||||
@import "inc/mixins";
|
||||
|
||||
@@ -4,14 +4,13 @@ body.fixed-layout {
|
||||
|
||||
#application-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
padding-bottom: 0;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
.application-layout-content > div {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -73,9 +72,6 @@ body.fixed-layout {
|
||||
}
|
||||
}
|
||||
|
||||
.embed__vis {
|
||||
}
|
||||
|
||||
.query__vis {
|
||||
table {
|
||||
border: 1px solid #f0f0f0;
|
||||
@@ -94,6 +90,7 @@ body.fixed-layout {
|
||||
.embed__vis {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.embed-heading {
|
||||
@@ -140,10 +137,6 @@ a.label-tag {
|
||||
}
|
||||
}
|
||||
|
||||
.schema-browser {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -156,7 +149,6 @@ a.label-tag {
|
||||
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
|
||||
.resizable-component.react-resizable {
|
||||
.react-resizable-handle-horizontal {
|
||||
@@ -486,13 +478,6 @@ nav .rg-bottom {
|
||||
}
|
||||
}
|
||||
|
||||
.query-page-wrapper {
|
||||
.container {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.datasource-small {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tag-separator {
|
||||
margin: 4px 3px 0 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { isEmpty, template } from "lodash";
|
||||
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Menu from "antd/lib/menu";
|
||||
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
export default function FavoritesDropdown({ fetch, urlTemplate }) {
|
||||
const [items, setItems] = useState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const noItems = isEmpty(items);
|
||||
const urlCompiled = useMemo(() => template(urlTemplate), [urlTemplate]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
(showLoadingState = true) => {
|
||||
setLoading(showLoadingState);
|
||||
fetch()
|
||||
.then(({ results }) => {
|
||||
setItems(results);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[fetch]
|
||||
);
|
||||
|
||||
// fetch items on init
|
||||
useEffect(() => {
|
||||
fetchItems(false);
|
||||
}, [fetchItems]);
|
||||
|
||||
// fetch items on click
|
||||
const onVisibleChange = visible => visible && fetchItems();
|
||||
|
||||
const menu = (
|
||||
<Menu className="favorites-dropdown">
|
||||
{noItems ? (
|
||||
<Menu.Item>
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className="fa fa-star" />
|
||||
</span>
|
||||
No favorites selected yet <HelpTrigger type="FAVORITES" />
|
||||
</Menu.Item>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<Menu.Item key={item.id}>
|
||||
<a href={urlCompiled(item)}>
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className="fa fa-star" />
|
||||
</span>
|
||||
{item.name}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
))
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={loading}
|
||||
trigger={["click"]}
|
||||
placement="bottomLeft"
|
||||
onVisibleChange={onVisibleChange}
|
||||
overlay={menu}>
|
||||
{loading ? <Icon type="loading" spin /> : <Icon type="down" />}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
FavoritesDropdown.propTypes = {
|
||||
fetch: PropTypes.func.isRequired,
|
||||
urlTemplate: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,262 +0,0 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
|
||||
import React, { useCallback, useRef } from "react";
|
||||
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Input from "antd/lib/input";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||
|
||||
import { currentUser, Auth, clientConfig } from "@/services/auth";
|
||||
import { Dashboard } from "@/services/dashboard";
|
||||
import { Query } from "@/services/query";
|
||||
import frontendVersion from "@/version.json";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
|
||||
import FavoritesDropdown from "./FavoritesDropdown";
|
||||
import "./index.less";
|
||||
|
||||
function onSearch(q) {
|
||||
navigateTo(`queries?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
function DesktopNavbar() {
|
||||
const showCreateDashboardDialog = useCallback(() => {
|
||||
CreateDashboardDialog.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-header" data-platform="desktop">
|
||||
<div>
|
||||
<Menu mode="horizontal" selectable={false}>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards" className="dropdown-menu-item">
|
||||
<Button href="dashboards">Dashboards</Button>
|
||||
<FavoritesDropdown fetch={Dashboard.favorites} urlTemplate="dashboard/${slug}" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries" className="dropdown-menu-item">
|
||||
<Button href="queries">Queries</Button>
|
||||
<FavoritesDropdown fetch={Query.favorites} urlTemplate="queries/${id}" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<Button href="alerts">Alerts</Button>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
{currentUser.canCreate() && (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
overlay={
|
||||
<Menu>
|
||||
{currentUser.hasPermission("create_query") && (
|
||||
<Menu.Item key="new-query">
|
||||
<a href="queries/new">New Query</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("create_dashboard") && (
|
||||
<Menu.Item key="new-dashboard">
|
||||
<a onMouseUp={showCreateDashboardDialog}>New Dashboard</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="new-alert">
|
||||
<a href="alerts/new">New Alert</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
}>
|
||||
<Button type="primary" data-test="CreateButton">
|
||||
Create <Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-logo">
|
||||
<a href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<Input.Search
|
||||
className="searchbar"
|
||||
placeholder="Search queries..."
|
||||
data-test="AppHeaderSearch"
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
<Menu mode="horizontal" selectable={false}>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger type="HOME" className="menu-item-button" />
|
||||
</Menu.Item>
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="settings">
|
||||
<Tooltip title="Settings">
|
||||
<Button href="data_sources" className="menu-item-button">
|
||||
<i className="fa fa-sliders" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Edit Profile</a>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="datasources">
|
||||
<a href="data_sources">Data Sources</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_users") && (
|
||||
<Menu.Item key="groups">
|
||||
<a href="groups">Groups</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_users") && (
|
||||
<Menu.Item key="users">
|
||||
<a href="users">Users</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("create_query") && (
|
||||
<Menu.Item key="snippets">
|
||||
<a href="query_snippets">Query Snippets</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="destinations">
|
||||
<a href="destinations">Alert Destinations</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="version" disabled>
|
||||
Version: {clientConfig.version}
|
||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||
<Tooltip title="Update Available" placement="rightTop">
|
||||
{" "}
|
||||
{/* eslint-disable react/jsx-no-target-blank */}
|
||||
<a
|
||||
href="https://version.redash.io/"
|
||||
className="update-available"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
<i className="fa fa-arrow-circle-down" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
<Button data-test="ProfileDropdown" className="profile-dropdown">
|
||||
<img src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||
<span>{currentUser.name}</span>
|
||||
<Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavbar() {
|
||||
const ref = useRef();
|
||||
|
||||
return (
|
||||
<div className="app-header" data-platform="mobile" ref={ref}>
|
||||
<div className="header-logo">
|
||||
<a href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
trigger={["click"]}
|
||||
getPopupContainer={() => ref.current} // so the overlay menu stays with the fixed header when page scrolls
|
||||
overlay={
|
||||
<Menu mode="vertical" selectable={false}>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<a href="dashboards">Dashboards</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<a href="queries">Queries</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<a href="alerts">Alerts</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<a href="users/me">Edit Profile</a>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{currentUser.isAdmin && (
|
||||
<Menu.Item key="settings">
|
||||
<a href="data_sources">Settings</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<a href="admin/status">System Status</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
<Menu.Item key="help">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href="https://redash.io/help" target="_blank" rel="noopener">
|
||||
Help
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
<Button>
|
||||
<Icon type="menu" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApplicationHeader() {
|
||||
return (
|
||||
<nav className="app-header-wrapper">
|
||||
<DesktopNavbar />
|
||||
<MobileNavbar />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
@mobileBreakpoint: ~"(max-width: 767px)";
|
||||
|
||||
nav .app-header {
|
||||
height: 49px;
|
||||
padding-bottom: 1px;
|
||||
box-sizing: content-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
|
||||
.darker {
|
||||
color: #333 !important;
|
||||
|
||||
&:hover {
|
||||
color: #2196f3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&[data-platform="mobile"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-item-button {
|
||||
padding: 0 15px;
|
||||
font-size: 18px;
|
||||
.darker();
|
||||
}
|
||||
|
||||
.ant-menu-root {
|
||||
margin: 0 10px;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
font-weight: 500;
|
||||
|
||||
.anticon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-platform="desktop"] .ant-btn:not(.ant-btn-primary) {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
background-color: transparent; //so it doesn't interfere with click animation of adjacent buttons
|
||||
.darker();
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
padding: 0;
|
||||
height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.anticon-down {
|
||||
font-size: 13px !important;
|
||||
transform: none;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s cubic-bezier(0.75, 0, 0.25, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-open .anticon-down svg,
|
||||
.anticon-down.ant-dropdown-open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
.ant-btn {
|
||||
padding-right: 5px;
|
||||
padding-left: 5px;
|
||||
margin-right: 30px;
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// this is a trick to get the dropdown menu to be placed at the bottom left
|
||||
// of the menu item and not the dropdown trigger
|
||||
.ant-dropdown-trigger {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
left: 10px;
|
||||
bottom: 5px;
|
||||
text-align: right;
|
||||
padding-top: 14px;
|
||||
padding-right: 10px;
|
||||
margin-right: 0;
|
||||
user-select: none; // or else double clicking it causes the header logo to get selected
|
||||
.darker();
|
||||
}
|
||||
}
|
||||
|
||||
.header-logo img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
width: 185px;
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
max-width: 130px; // arbitrary, prevents layout mess up if username long
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ant-btn,
|
||||
.menu-item-button {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ant-menu-root {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.searchbar {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
&[data-platform="desktop"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-platform="mobile"] {
|
||||
display: flex;
|
||||
padding: 0 15px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
.app-header-wrapper {
|
||||
margin-top: 59px !important; // compensate for app header fixed position
|
||||
}
|
||||
}
|
||||
|
||||
.update-available {
|
||||
display: inline !important;
|
||||
|
||||
.fa {
|
||||
color: #52c41a;
|
||||
vertical-align: text-bottom;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item .help-trigger {
|
||||
display: inline;
|
||||
color: #2196f3;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu.favorites-dropdown {
|
||||
margin-left: -10px;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { first } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
|
||||
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
|
||||
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
|
||||
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
|
||||
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
|
||||
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
|
||||
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
|
||||
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
|
||||
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
|
||||
|
||||
import VersionInfo from "./VersionInfo";
|
||||
import "./DesktopNavbar.less";
|
||||
|
||||
function NavbarSection({ inlineCollapsed, children, ...props }) {
|
||||
return (
|
||||
<Menu
|
||||
selectable={false}
|
||||
mode={inlineCollapsed ? "inline" : "vertical"}
|
||||
inlineCollapsed={inlineCollapsed}
|
||||
theme="dark"
|
||||
{...props}>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopNavbar() {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
|
||||
const canCreateQuery = currentUser.hasPermission("create_query");
|
||||
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
|
||||
const canCreateAlert = currentUser.hasPermission("list_alerts");
|
||||
|
||||
return (
|
||||
<div className="desktop-navbar">
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
|
||||
<div>
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</Link>
|
||||
</div>
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<Link href="dashboards">
|
||||
<DesktopOutlinedIcon />
|
||||
<span>Dashboards</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<Link href="queries">
|
||||
<CodeOutlinedIcon />
|
||||
<span>Queries</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<Link href="alerts">
|
||||
<AlertOutlinedIcon />
|
||||
<span>Alerts</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
|
||||
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
|
||||
<Menu.SubMenu
|
||||
key="create"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
title={
|
||||
<React.Fragment>
|
||||
<span data-test="CreateButton">
|
||||
<PlusOutlinedIcon />
|
||||
<span>Create</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
}>
|
||||
{canCreateQuery && (
|
||||
<Menu.Item key="new-query">
|
||||
<Link href="queries/new" data-test="CreateQueryMenuItem">
|
||||
New Query
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCreateDashboard && (
|
||||
<Menu.Item key="new-dashboard">
|
||||
<a data-test="CreateDashboardMenuItem" onMouseUp={() => CreateDashboardDialog.showModal()}>
|
||||
New Dashboard
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canCreateAlert && (
|
||||
<Menu.Item key="new-alert">
|
||||
<Link data-test="CreateAlertMenuItem" href="alerts/new">
|
||||
New Alert
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed}>
|
||||
<Menu.Item key="help">
|
||||
<HelpTrigger showTooltip={false} type="HOME">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
<span>Help</span>
|
||||
</HelpTrigger>
|
||||
</Menu.Item>
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<Link href={firstSettingsTab.path} data-test="SettingsLink">
|
||||
<SettingOutlinedIcon />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
</NavbarSection>
|
||||
|
||||
<NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
|
||||
<Menu.SubMenu
|
||||
key="profile"
|
||||
popupClassName="desktop-navbar-submenu"
|
||||
title={
|
||||
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
|
||||
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
|
||||
<span>{currentUser.name}</span>
|
||||
</span>
|
||||
}>
|
||||
<Menu.Item key="profile">
|
||||
<Link href="users/me">Profile</Link>
|
||||
</Menu.Item>
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="logout">
|
||||
<a data-test="LogOutButton" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="version" disabled className="version-info">
|
||||
<VersionInfo />
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</NavbarSection>
|
||||
|
||||
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
|
||||
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
@backgroundColor: #001529;
|
||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||
@textColor: rgba(255, 255, 255, 0.75);
|
||||
|
||||
.desktop-navbar {
|
||||
background: @backgroundColor;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
&-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&-logo.ant-menu {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
height: 40px;
|
||||
transition: all 270ms;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
img {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.help-trigger {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
&:not(.ant-menu-inline-collapsed) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
|
||||
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
|
||||
display: inline-block;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-menu-item-divider {
|
||||
background: @dividerColor;
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
font-weight: 500;
|
||||
color: @textColor;
|
||||
|
||||
&.ant-menu-submenu-open,
|
||||
&.ant-menu-submenu-active,
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a,
|
||||
span,
|
||||
.anticon {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn.desktop-navbar-collapse-button {
|
||||
background-color: @backgroundColor;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: @textColor;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:after {
|
||||
animation: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-navbar-profile-menu {
|
||||
.desktop-navbar-profile-menu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.profile__image_thumb {
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.profile__image_thumb + span {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
|
||||
// styles from Antd
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-inline-collapsed {
|
||||
.ant-menu-submenu-title {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important;
|
||||
}
|
||||
|
||||
.desktop-navbar-profile-menu-title {
|
||||
.profile__image_thumb + span {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-navbar-submenu {
|
||||
.ant-menu {
|
||||
.ant-menu-item-divider {
|
||||
background: @dividerColor;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
font-weight: 500;
|
||||
color: @textColor;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a,
|
||||
span,
|
||||
.anticon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.zmdi,
|
||||
.fa {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&.version-info {
|
||||
height: auto;
|
||||
line-height: normal;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { first } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Link from "@/components/Link";
|
||||
import { Auth, currentUser } from "@/services/auth";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
import logoUrl from "@/assets/images/redash_icon_small.png";
|
||||
|
||||
import "./MobileNavbar.less";
|
||||
|
||||
export default function MobileNavbar({ getPopupContainer }) {
|
||||
const firstSettingsTab = first(settingsMenu.getAvailableItems());
|
||||
|
||||
return (
|
||||
<div className="mobile-navbar">
|
||||
<div className="mobile-navbar-logo">
|
||||
<Link href="./">
|
||||
<img src={logoUrl} alt="Redash" />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
overlayStyle={{ minWidth: 200 }}
|
||||
trigger={["click"]}
|
||||
getPopupContainer={getPopupContainer} // so the overlay menu stays with the fixed header when page scrolls
|
||||
overlay={
|
||||
<Menu mode="vertical" theme="dark" selectable={false} className="mobile-navbar-menu">
|
||||
{currentUser.hasPermission("list_dashboards") && (
|
||||
<Menu.Item key="dashboards">
|
||||
<Link href="dashboards">Dashboards</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("view_query") && (
|
||||
<Menu.Item key="queries">
|
||||
<Link href="queries">Queries</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("list_alerts") && (
|
||||
<Menu.Item key="alerts">
|
||||
<Link href="alerts">Alerts</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="profile">
|
||||
<Link href="users/me">Edit Profile</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{firstSettingsTab && (
|
||||
<Menu.Item key="settings">
|
||||
<Link href={firstSettingsTab.path}>Settings</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && (
|
||||
<Menu.Item key="status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{currentUser.hasPermission("super_admin") && <Menu.Divider />}
|
||||
<Menu.Item key="help">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<Link href="https://redash.io/help" target="_blank" rel="noopener">
|
||||
Help
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logout" onClick={() => Auth.logout()}>
|
||||
Log out
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}>
|
||||
<Button className="mobile-navbar-toggle-button" ghost>
|
||||
<MenuOutlinedIcon />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MobileNavbar.propTypes = {
|
||||
getPopupContainer: PropTypes.func,
|
||||
};
|
||||
|
||||
MobileNavbar.defaultProps = {
|
||||
getPopupContainer: null,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
@backgroundColor: #001529;
|
||||
@dividerColor: rgba(255, 255, 255, 0.5);
|
||||
@textColor: rgba(255, 255, 255, 0.75);
|
||||
|
||||
.mobile-navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: @backgroundColor;
|
||||
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
|
||||
padding: 0 15px;
|
||||
height: 100%;
|
||||
|
||||
&-logo {
|
||||
img {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn.mobile-navbar-toggle-button {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-navbar-menu {
|
||||
.ant-dropdown-menu-item {
|
||||
font-weight: 500;
|
||||
color: @textColor;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background: @dividerColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import Link from "@/components/Link";
|
||||
import { clientConfig, currentUser } from "@/services/auth";
|
||||
import frontendVersion from "@/version.json";
|
||||
|
||||
export default function VersionInfo() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
Version: {clientConfig.version}
|
||||
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
|
||||
</div>
|
||||
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
|
||||
<div className="m-t-10">
|
||||
{/* eslint-disable react/jsx-no-target-blank */}
|
||||
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
|
||||
Update Available
|
||||
<i className="fa fa-external-link m-l-5" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import DesktopNavbar from "./DesktopNavbar";
|
||||
import MobileNavbar from "./MobileNavbar";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function ApplicationLayout({ children }) {
|
||||
const mobileNavbarContainerRef = useRef();
|
||||
|
||||
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="application-layout-side-menu">
|
||||
<DynamicComponent name="ApplicationDesktopNavbar">
|
||||
<DesktopNavbar />
|
||||
</DynamicComponent>
|
||||
</div>
|
||||
<div className="application-layout-content">
|
||||
<nav className="application-layout-top-menu" ref={mobileNavbarContainerRef}>
|
||||
<DynamicComponent name="ApplicationMobileNavbar" getPopupContainer={getMobileNavbarPopupContainer}>
|
||||
<MobileNavbar getPopupContainer={getMobileNavbarPopupContainer} />
|
||||
</DynamicComponent>
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ApplicationLayout.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
ApplicationLayout.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
@mobileBreakpoint: ~"(max-width: 767px)";
|
||||
|
||||
body #application-root {
|
||||
@topMenuHeight: 49px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100vh;
|
||||
|
||||
.application-layout-side-menu {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.application-layout-top-menu {
|
||||
height: @topMenuHeight;
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.application-layout-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
flex: 1 1 auto;
|
||||
padding-bottom: 15px;
|
||||
|
||||
@media @mobileBreakpoint {
|
||||
margin-top: @topMenuHeight; // compensate for app header fixed position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.fixed-layout #application-root {
|
||||
.application-layout-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body.headless #application-root {
|
||||
.application-layout-side-menu,
|
||||
.application-layout-top-menu {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.application-layout-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fixes for proper snapshots in Percy (move vertical scroll to body level
|
||||
// to capture entire page, otherwise it wll be cut by viewport)
|
||||
@media only percy {
|
||||
body #application-root {
|
||||
height: auto;
|
||||
|
||||
.application-layout-side-menu {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.application-layout-content {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { isObject, get } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "./ErrorMessage.less";
|
||||
|
||||
function getErrorMessageByStatus(status, defaultMessage) {
|
||||
switch (status) {
|
||||
case 404:
|
||||
@@ -37,17 +39,13 @@ export default function ErrorMessage({ error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<div className="fixed-container" data-test="ErrorMessage">
|
||||
<div className="container">
|
||||
<div className="col-md-8 col-md-push-2">
|
||||
<div className="error-state bg-white tiled">
|
||||
<div className="error-state__icon">
|
||||
<i className="zmdi zmdi-alert-circle-o" />
|
||||
</div>
|
||||
<div className="error-state__details">
|
||||
<h4>{getErrorMessage(error)}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="error-message-container" data-test="ErrorMessage">
|
||||
<div className="error-state bg-white tiled">
|
||||
<div className="error-state__icon">
|
||||
<i className="zmdi zmdi-alert-circle-o" />
|
||||
</div>
|
||||
<div className="error-state__details">
|
||||
<h4>{getErrorMessage(error)}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
17
client/app/components/ApplicationArea/ErrorMessage.less
Normal file
17
client/app/components/ApplicationArea/ErrorMessage.less
Normal file
@@ -0,0 +1,17 @@
|
||||
.error-message-container {
|
||||
width: 100%;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
.error-state {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 65%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import UniversalRouter from "universal-router";
|
||||
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
|
||||
@@ -14,6 +14,12 @@ function generateRouteKey() {
|
||||
.substr(2);
|
||||
}
|
||||
|
||||
export const CurrentRouteContext = React.createContext(null);
|
||||
|
||||
export function useCurrentRoute() {
|
||||
return useContext(CurrentRouteContext);
|
||||
}
|
||||
|
||||
export function stripBase(href) {
|
||||
// Resolve provided link and '' (root) relative to document's base.
|
||||
// If provided href is not related to current document (does not
|
||||
@@ -53,7 +59,7 @@ export default function Router({ routes, onRouteChange }) {
|
||||
errorHandlerRef.current.reset();
|
||||
}
|
||||
|
||||
const pathname = stripBase(location.path);
|
||||
const pathname = stripBase(location.path) || "/";
|
||||
|
||||
// This is a optimization for route resolver: if current route was already resolved
|
||||
// from this path - do nothing. It also prevents router from using outdated route in a case
|
||||
@@ -95,6 +101,7 @@ export default function Router({ routes, onRouteChange }) {
|
||||
|
||||
return () => {
|
||||
isAbandoned = true;
|
||||
currentPathRef.current = null;
|
||||
unlisten();
|
||||
};
|
||||
}, [routes]);
|
||||
@@ -108,9 +115,11 @@ export default function Router({ routes, onRouteChange }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
||||
{currentRoute.render(currentRoute)}
|
||||
</ErrorBoundary>
|
||||
<CurrentRouteContext.Provider value={currentRoute}>
|
||||
<ErrorBoundary ref={errorHandlerRef} renderError={error => <ErrorMessage error={error} />}>
|
||||
{currentRoute.render(currentRoute)}
|
||||
</ErrorBoundary>
|
||||
</CurrentRouteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import routes from "@/pages";
|
||||
import routes from "@/services/routes";
|
||||
import Router from "./Router";
|
||||
import handleNavigationIntent from "./handleNavigationIntent";
|
||||
import ErrorMessage from "./ErrorMessage";
|
||||
@@ -33,5 +33,5 @@ export default function ApplicationArea() {
|
||||
return <ErrorMessage error={unhandledError} />;
|
||||
}
|
||||
|
||||
return <Router routes={routes} onRouteChange={setCurrentRoute} />;
|
||||
return <Router routes={routes.items} onRouteChange={setCurrentRoute} />;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
|
||||
import { Auth } from "@/services/auth";
|
||||
import { policy } from "@/services/policy";
|
||||
import organizationStatus from "@/services/organizationStatus";
|
||||
import ApplicationHeader from "./ApplicationHeader";
|
||||
import ApplicationLayout from "./ApplicationLayout";
|
||||
import ErrorMessage from "./ErrorMessage";
|
||||
|
||||
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
|
||||
@@ -17,7 +18,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
Promise.all([Auth.requireSession(), organizationStatus.refresh()])
|
||||
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
|
||||
.then(() => {
|
||||
if (!isCancelled) {
|
||||
setIsAuthenticated(!!Auth.isAuthenticated());
|
||||
@@ -47,8 +48,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ApplicationHeader />
|
||||
<ApplicationLayout>
|
||||
<React.Fragment key={currentRoute.key}>
|
||||
<ErrorBoundary renderError={error => <ErrorMessage error={error} />}>
|
||||
<ErrorBoundaryContext.Consumer>
|
||||
@@ -58,7 +58,7 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) {
|
||||
</ErrorBoundaryContext.Consumer>
|
||||
</ErrorBoundary>
|
||||
</React.Fragment>
|
||||
</React.Fragment>
|
||||
</ApplicationLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import Card from "antd/lib/card";
|
||||
import Button from "antd/lib/button";
|
||||
import Typography from "antd/lib/typography";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import Link from "@/components/Link";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
import OrgSettings from "@/services/organizationSettings";
|
||||
@@ -65,8 +66,8 @@ function BeaconConsent() {
|
||||
</div>
|
||||
<div className="m-t-15">
|
||||
<Text type="secondary">
|
||||
You can change this setting anytime from the <a href="settings/organization">Organization Settings</a>{" "}
|
||||
page.
|
||||
You can change this setting anytime from the{" "}
|
||||
<Link href="settings/organization">Organization Settings</Link> page.
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import "./CodeBlock.less";
|
||||
|
||||
export default class CodeBlock extends React.Component {
|
||||
@@ -59,7 +60,7 @@ export default class CodeBlock extends React.Component {
|
||||
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || "Copy"}>
|
||||
<Button icon="copy" type="dashed" size="small" onClick={this.copy} />
|
||||
<Button icon={<CopyOutlinedIcon />} type="dashed" size="small" onClick={this.copy} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Steps from "antd/lib/steps";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import Link from "@/components/Link";
|
||||
import { PreviewCard } from "@/components/PreviewCard";
|
||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||
import DynamicForm from "@/components/dynamic-form/DynamicForm";
|
||||
@@ -118,9 +119,9 @@ class CreateSourceDialog extends React.Component {
|
||||
{selectedType.type === "databricks" && (
|
||||
<small>
|
||||
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
|
||||
<a href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
<Link href="https://databricks.com/spark/odbc-driver-download" target="_blank" rel="noopener noreferrer">
|
||||
Driver Download Terms and Conditions
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</small>
|
||||
)}
|
||||
@@ -154,7 +155,7 @@ class CreateSourceDialog extends React.Component {
|
||||
footer={
|
||||
currentStep === StepEnum.SELECT_TYPE
|
||||
? [
|
||||
<Button key="cancel" onClick={() => dialog.dismiss()}>
|
||||
<Button key="cancel" onClick={() => dialog.dismiss()} data-test="CreateSourceCancelButton">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" disabled>
|
||||
@@ -171,7 +172,7 @@ class CreateSourceDialog extends React.Component {
|
||||
form="sourceForm"
|
||||
type="primary"
|
||||
loading={savingSource}
|
||||
data-test="CreateSourceButton">
|
||||
data-test="CreateSourceSaveButton">
|
||||
Create
|
||||
</Button>,
|
||||
]
|
||||
|
||||
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
30
client/app/components/DialogWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ModalProps } from "antd/lib/modal/Modal";
|
||||
|
||||
export interface DialogProps<ROk, RCancel> {
|
||||
props: ModalProps;
|
||||
close: (result: ROk) => void;
|
||||
dismiss: (result: RCancel) => void;
|
||||
}
|
||||
|
||||
export type DialogWrapperChildProps<ROk, RCancel> = {
|
||||
dialog: DialogProps<ROk, RCancel>;
|
||||
};
|
||||
|
||||
export type DialogComponentType<ROk = void, P = {}, RCancel = void> = React.ComponentType<
|
||||
DialogWrapperChildProps<ROk, RCancel> & P
|
||||
>;
|
||||
|
||||
export function wrap<ROk = void, P = {}, RCancel = void>(
|
||||
DialogComponent: DialogComponentType<ROk, P, RCancel>
|
||||
): {
|
||||
Component: DialogComponentType<ROk, P, RCancel>;
|
||||
showModal: (
|
||||
props?: P
|
||||
) => {
|
||||
update: (props: P) => void;
|
||||
onClose: (handler: (result: ROk) => Promise<void>) => void;
|
||||
onDismiss: (handler: (result: RCancel) => Promise<void>) => void;
|
||||
close: (result: ROk) => void;
|
||||
dismiss: (result: RCancel) => void;
|
||||
};
|
||||
};
|
||||
@@ -100,7 +100,7 @@ function EditParameterSettingsDialog(props) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function onConfirm(e) {
|
||||
function onConfirm() {
|
||||
// update title to default
|
||||
if (!param.title) {
|
||||
// forced to do this cause param won't update in time for save
|
||||
@@ -109,8 +109,6 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
props.dialog.close(param);
|
||||
|
||||
e.preventDefault(); // stops form redirect
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -132,7 +130,7 @@ function EditParameterSettingsDialog(props) {
|
||||
{isNew ? "Add Parameter" : "OK"}
|
||||
</Button>,
|
||||
]}>
|
||||
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
|
||||
<Form layout="horizontal" onFinish={onConfirm} id="paramForm">
|
||||
{isNew && (
|
||||
<NameInput
|
||||
name={param.name}
|
||||
|
||||
@@ -3,7 +3,12 @@ import PropTypes from "prop-types";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
|
||||
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
|
||||
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
|
||||
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
|
||||
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
|
||||
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
|
||||
|
||||
import QueryResultsLink from "./QueryResultsLink";
|
||||
|
||||
@@ -13,14 +18,14 @@ export default function QueryControlDropdown(props) {
|
||||
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
|
||||
<Menu.Item>
|
||||
<a target="_self" onClick={() => props.openAddToDashboardForm(props.selectedTab)}>
|
||||
<Icon type="plus-circle" theme="filled" /> Add to Dashboard
|
||||
<PlusCircleFilledIcon /> Add to Dashboard
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!props.query.isNew() && (
|
||||
<Menu.Item>
|
||||
<a onClick={() => props.showEmbedDialog(props.query, props.selectedTab)} data-test="ShowEmbedDialogButton">
|
||||
<Icon type="share-alt" /> Embed Elsewhere
|
||||
<ShareAltOutlinedIcon /> Embed Elsewhere
|
||||
</a>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -32,7 +37,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file" /> Download as CSV File
|
||||
<FileOutlinedIcon /> Download as CSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -43,7 +48,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file" /> Download as TSV File
|
||||
<FileOutlinedIcon /> Download as TSV File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -54,7 +59,7 @@ export default function QueryControlDropdown(props) {
|
||||
queryResult={props.queryResult}
|
||||
embed={props.embed}
|
||||
apiKey={props.apiKey}>
|
||||
<Icon type="file-excel" /> Download as Excel File
|
||||
<FileExcelOutlinedIcon /> Download as Excel File
|
||||
</QueryResultsLink>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
@@ -63,7 +68,7 @@ export default function QueryControlDropdown(props) {
|
||||
return (
|
||||
<Dropdown trigger={["click"]} overlay={menu} overlayClassName="query-control-dropdown-overlay">
|
||||
<Button data-test="QueryControlDropdownButton">
|
||||
<Icon type="ellipsis" rotate={90} />
|
||||
<EllipsisOutlinedIcon rotate={90} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
export default function QueryResultsLink(props) {
|
||||
let href = "";
|
||||
@@ -17,9 +18,9 @@ export default function QueryResultsLink(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
<Link target="_blank" rel="noopener noreferrer" disabled={props.disabled} href={href} download>
|
||||
{props.children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
|
||||
|
||||
export default function EditVisualizationButton(props) {
|
||||
return (
|
||||
@@ -9,7 +9,7 @@ export default function EditVisualizationButton(props) {
|
||||
data-test="EditVisualization"
|
||||
className="edit-visualization"
|
||||
onClick={() => props.openVisualizationEditor(props.selectedTab)}>
|
||||
<Icon type="form" />
|
||||
<FormOutlinedIcon />
|
||||
<span className="hidden-xs hidden-s hidden-m">Edit Visualization</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Drawer from "antd/lib/drawer";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Link from "@/components/Link";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import BigMessage from "@/components/BigMessage";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
|
||||
@@ -149,9 +150,9 @@ export default class HelpTrigger extends React.Component {
|
||||
{this.props.children}
|
||||
</a>
|
||||
) : (
|
||||
<a href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
||||
<Link href={url || this.getUrl()} className={className} rel="noopener noreferrer" target="_blank">
|
||||
{this.props.children}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Drawer
|
||||
@@ -167,14 +168,14 @@ export default class HelpTrigger extends React.Component {
|
||||
{url && (
|
||||
<Tooltip title="Open page in a new window" placement="left">
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={url} target="_blank">
|
||||
<Link href={url} target="_blank">
|
||||
<i className="fa fa-external-link" />
|
||||
</a>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Close" placement="bottom">
|
||||
<a onClick={this.closeDrawer}>
|
||||
<Icon type="close" />
|
||||
<CloseOutlinedIcon />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -201,9 +202,9 @@ export default class HelpTrigger extends React.Component {
|
||||
Something went wrong.
|
||||
<br />
|
||||
{/* eslint-disable-next-line react/jsx-no-target-blank */}
|
||||
<a href={this.state.error} target="_blank" rel="noopener">
|
||||
<Link href={this.state.error} target="_blank" rel="noopener">
|
||||
Click here
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
to open the page in a new window.
|
||||
</BigMessage>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import Input from "antd/lib/input";
|
||||
import Icon from "antd/lib/icon";
|
||||
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
export default class InputWithCopy extends React.Component {
|
||||
@@ -42,7 +42,7 @@ export default class InputWithCopy extends React.Component {
|
||||
render() {
|
||||
const copyButton = (
|
||||
<Tooltip title={this.state.copied || "Copy"}>
|
||||
<Icon type="copy" style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||
<CopyOutlinedIcon style={{ cursor: "pointer" }} onClick={this.copy} />
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
26
client/app/components/Link.jsx
Normal file
26
client/app/components/Link.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import Button from "antd/lib/button";
|
||||
|
||||
function DefaultLinkComponent(props) {
|
||||
return <a {...props} />; // eslint-disable-line jsx-a11y/anchor-has-content
|
||||
}
|
||||
|
||||
function Link(props) {
|
||||
return <Link.Component {...props} />;
|
||||
}
|
||||
|
||||
Link.Component = DefaultLinkComponent;
|
||||
|
||||
function DefaultButtonLinkComponent(props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
function ButtonLink(props) {
|
||||
return <ButtonLink.Component {...props} />;
|
||||
}
|
||||
|
||||
ButtonLink.Component = DefaultButtonLinkComponent;
|
||||
|
||||
Link.Button = ButtonLink;
|
||||
|
||||
export default Link;
|
||||
@@ -7,7 +7,7 @@ export default function NoTaggedObjectsFound({ objectType, tags }) {
|
||||
return (
|
||||
<BigMessage icon="fa-tags">
|
||||
No {objectType} found tagged with
|
||||
<TagsControl className="inline-tags-control" tags={Array.from(tags)} />.
|
||||
<TagsControl className="inline-tags-control" tags={Array.from(tags)} tagSeparator={"+"} />.
|
||||
</BigMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
export default function PageHeader({ title }) {
|
||||
return (
|
||||
<div className="page-header-wrapper row p-l-15 p-r-15 m-b-10 m-l-0 m-r-0">
|
||||
<div className="col-sm-9 p-l-0 p-r-0">
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
23
client/app/components/PageHeader/index.jsx
Normal file
23
client/app/components/PageHeader/index.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "./index.less";
|
||||
|
||||
export default function PageHeader({ title, actions }) {
|
||||
return (
|
||||
<div className="page-header-wrapper">
|
||||
<h3>{title}</h3>
|
||||
{actions && <div className="page-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
title: PropTypes.string,
|
||||
actions: PropTypes.node,
|
||||
};
|
||||
|
||||
PageHeader.defaultProps = {
|
||||
title: "",
|
||||
actions: null,
|
||||
};
|
||||
20
client/app/components/PageHeader/index.less
Executable file
20
client/app/components/PageHeader/index.less
Executable file
@@ -0,0 +1,20 @@
|
||||
.page-header-wrapper {
|
||||
margin: 15px 0 10px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.page-header-actions {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0 0 15px;
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,38 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Pagination from "antd/lib/pagination";
|
||||
|
||||
export default function Paginator({ page, itemsPerPage, totalCount, onChange }) {
|
||||
if (totalCount <= itemsPerPage) {
|
||||
const MIN_ITEMS_PER_PAGE = 5;
|
||||
|
||||
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
|
||||
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="paginator-container">
|
||||
<Pagination defaultCurrent={page} defaultPageSize={itemsPerPage} total={totalCount} onChange={onChange} />
|
||||
<Pagination
|
||||
showSizeChanger={showPageSizeSelect}
|
||||
pageSizeOptions={["5", "10", "20", "50", "100"]}
|
||||
onShowSizeChange={(_, size) => onPageSizeChange(size)}
|
||||
defaultCurrent={page}
|
||||
pageSize={pageSize}
|
||||
total={totalCount}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Paginator.propTypes = {
|
||||
page: PropTypes.number.isRequired,
|
||||
itemsPerPage: PropTypes.number.isRequired,
|
||||
showPageSizeSelect: PropTypes.bool,
|
||||
pageSize: PropTypes.number.isRequired,
|
||||
totalCount: PropTypes.number.isRequired,
|
||||
onPageSizeChange: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
Paginator.defaultProps = {
|
||||
showPageSizeSelect: false,
|
||||
onChange: () => {},
|
||||
onPageSizeChange: () => {},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import Select from "antd/lib/select";
|
||||
import Table from "antd/lib/table";
|
||||
import Popover from "antd/lib/popover";
|
||||
import Button from "antd/lib/button";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Tag from "antd/lib/tag";
|
||||
import Input from "antd/lib/input";
|
||||
import Radio from "antd/lib/radio";
|
||||
@@ -19,6 +18,11 @@ import { ParameterMappingType } from "@/services/widget";
|
||||
import { Parameter, cloneParameter } from "@/services/parameters";
|
||||
import HelpTrigger from "@/components/HelpTrigger";
|
||||
|
||||
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
|
||||
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
|
||||
|
||||
import "./ParameterMappingInput.less";
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -181,7 +185,7 @@ export class ParameterMappingInput extends React.Component {
|
||||
Existing dashboard parameter{" "}
|
||||
{noExisting ? (
|
||||
<Tooltip title="There are no dashboard parameters corresponding to this data type">
|
||||
<Icon type="question-circle" theme="filled" />
|
||||
<QuestionCircleFilledIcon />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Radio>
|
||||
@@ -355,7 +359,7 @@ class MappingEditor extends React.Component {
|
||||
visible={visible}
|
||||
onVisibleChange={this.onVisibleChange}>
|
||||
<Button size="small" type="dashed" data-test={`EditParamMappingButon-${mapping.param.name}`}>
|
||||
<Icon type="edit" />
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
@@ -434,10 +438,10 @@ class TitleEditor extends React.Component {
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="small" type="dashed" onClick={this.hide}>
|
||||
<Icon type="close" />
|
||||
<CloseOutlinedIcon />
|
||||
</Button>
|
||||
<Button size="small" type="dashed" onClick={this.save}>
|
||||
<Icon type="check" />
|
||||
<CheckOutlinedIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -460,7 +464,7 @@ class TitleEditor extends React.Component {
|
||||
visible={this.state.showPopup}
|
||||
onVisibleChange={this.onPopupVisibleChange}>
|
||||
<Button size="small" type="dashed">
|
||||
<Icon type="edit" />
|
||||
<EditOutlinedIcon />
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~antd/lib/input-number/style/index'; // for ant @vars
|
||||
@import "~antd/lib/input-number/style/index"; // for ant @vars
|
||||
|
||||
@input-dirty: #fffce1;
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
}
|
||||
|
||||
&[data-dirty] {
|
||||
.@{ant-prefix}-input, // covers also ant date component
|
||||
.@{ant-prefix}-input,
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selection {
|
||||
.@{ant-prefix}-select-selector,
|
||||
.@{ant-prefix}-picker {
|
||||
background-color: @input-dirty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
// PreviewCard
|
||||
|
||||
@@ -42,7 +43,7 @@ PreviewCard.defaultProps = {
|
||||
// UserPreviewCard
|
||||
|
||||
export function UserPreviewCard({ user, withLink, children, ...props }) {
|
||||
const title = withLink ? <a href={"users/" + user.id}>{user.name}</a> : user.name;
|
||||
const title = withLink ? <Link href={"users/" + user.id}>{user.name}</Link> : user.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={user.profile_image_url} title={title} body={user.email}>
|
||||
{children}
|
||||
@@ -68,8 +69,8 @@ UserPreviewCard.defaultProps = {
|
||||
// DataSourcePreviewCard
|
||||
|
||||
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
|
||||
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <a href={"data_sources/" + dataSource.id}>{dataSource.name}</a> : dataSource.name;
|
||||
const imageUrl = `static/images/db-logos/${dataSource.type}.png`;
|
||||
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
|
||||
return (
|
||||
<PreviewCard {...props} imageUrl={imageUrl} title={title}>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { VisualizationType } from "@redash/viz/lib";
|
||||
import Link from "@/components/Link";
|
||||
import VisualizationName from "@/components/visualizations/VisualizationName";
|
||||
|
||||
import "./QueryLink.less";
|
||||
@@ -21,9 +22,9 @@ function QueryLink({ query, visualization, readOnly }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={readOnly ? null : getUrl()} className="query-link">
|
||||
<Link href={readOnly ? null : getUrl()} className="query-link">
|
||||
<VisualizationName visualization={visualization} /> <span>{query.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from "react";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
import location from "@/services/location";
|
||||
import settingsMenu from "@/services/settingsMenu";
|
||||
|
||||
function wrapSettingsTab(options, WrappedComponent) {
|
||||
if (options) {
|
||||
settingsMenu.add(options);
|
||||
}
|
||||
function wrapSettingsTab(id, options, WrappedComponent) {
|
||||
settingsMenu.add(id, options);
|
||||
|
||||
return function SettingsTab(props) {
|
||||
const activeItem = settingsMenu.getActiveItem(location.path);
|
||||
@@ -17,15 +16,13 @@ function wrapSettingsTab(options, WrappedComponent) {
|
||||
<PageHeader title="Settings" />
|
||||
<div className="bg-white tiled">
|
||||
<Menu selectedKeys={[activeItem && activeItem.title]} selectable={false} mode="horizontal">
|
||||
{settingsMenu.items
|
||||
.filter(item => item.isAvailable())
|
||||
.map(item => (
|
||||
<Menu.Item key={item.title}>
|
||||
<a href={item.path} data-test="SettingsScreenItem">
|
||||
{item.title}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
))}
|
||||
{settingsMenu.getAvailableItems().map(item => (
|
||||
<Menu.Item key={item.title}>
|
||||
<Link href={item.path} data-test="SettingsScreenItem">
|
||||
{item.title}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
<div className="p-15">
|
||||
<div>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tabs from "antd/lib/tabs";
|
||||
import Menu from "antd/lib/menu";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Link from "@/components/Link";
|
||||
|
||||
import "./layout.less";
|
||||
|
||||
export default function Layout({ activeTab, children }) {
|
||||
return (
|
||||
<div className="container admin-page-layout">
|
||||
<PageHeader title="Admin" />
|
||||
|
||||
<div className="bg-white tiled">
|
||||
<Tabs className="admin-page-layout-tabs" defaultActiveKey={activeTab} animated={false} tabBarGutter={0}>
|
||||
<Tabs.TabPane key="system_status" tab={<a href="admin/status">System Status</a>}>
|
||||
{activeTab === "system_status" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="jobs" tab={<a href="admin/queries/jobs">RQ Status</a>}>
|
||||
{activeTab === "jobs" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="outdated_queries" tab={<a href="admin/queries/outdated">Outdated Queries</a>}>
|
||||
{activeTab === "outdated_queries" ? children : null}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<div className="admin-page-layout">
|
||||
<div className="container">
|
||||
<PageHeader title="Admin" />
|
||||
<div className="bg-white tiled">
|
||||
<Menu selectedKeys={[activeTab]} selectable={false} mode="horizontal">
|
||||
<Menu.Item key="system_status">
|
||||
<Link href="admin/status">System Status</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="jobs">
|
||||
<Link href="admin/queries/jobs">RQ Status</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="outdated_queries">
|
||||
<Link href="admin/queries/outdated">Outdated Queries</Link>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,11 +35,11 @@ CounterCard.defaultProps = {
|
||||
|
||||
const queryJobsColumns = [
|
||||
{ title: "Queue", dataIndex: "origin" },
|
||||
{ title: "Query ID", dataIndex: "meta.query_id" },
|
||||
{ title: "Org ID", dataIndex: "meta.org_id" },
|
||||
{ title: "Data Source ID", dataIndex: "meta.data_source_id" },
|
||||
{ title: "User ID", dataIndex: "meta.user_id" },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: "meta.scheduled" }),
|
||||
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
|
||||
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
|
||||
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
|
||||
{ title: "User ID", dataIndex: ["meta", "user_id"] },
|
||||
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
|
||||
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
|
||||
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
|
||||
];
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
.admin-page-layout {
|
||||
max-width: 100%;
|
||||
|
||||
&-tabs.ant-tabs {
|
||||
> .ant-tabs-bar {
|
||||
margin: 0;
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 12px 16px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Input from "antd/lib/input";
|
||||
import { includes, isEmpty } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
import Link from "@/components/Link";
|
||||
import EmptyState from "@/components/items-list/components/EmptyState";
|
||||
|
||||
import "./CardsList.less";
|
||||
@@ -44,10 +45,10 @@ export default class CardsList extends React.Component {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
renderListItem(item) {
|
||||
return (
|
||||
<a key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
||||
<Link key={`card${item.id}`} className="visual-card" onClick={item.onClick} href={item.href}>
|
||||
<img alt={item.title} src={item.imgSrc} />
|
||||
<h3>{item.title}</h3>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { trim } from "lodash";
|
||||
import React, { useState } from "react";
|
||||
import { axios } from "@/services/axios";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import DynamicComponent from "@/components/DynamicComponent";
|
||||
@@ -8,6 +7,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import navigateTo from "@/components/ApplicationArea/navigateTo";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import { policy } from "@/services/policy";
|
||||
import { Dashboard } from "@/services/dashboard";
|
||||
|
||||
function CreateDashboardDialog({ dialog }) {
|
||||
const [name, setName] = useState("");
|
||||
@@ -25,9 +25,9 @@ function CreateDashboardDialog({ dialog }) {
|
||||
if (name !== "") {
|
||||
setSaveInProgress(true);
|
||||
|
||||
axios.post("api/dashboards", { name }).then(data => {
|
||||
Dashboard.save({ name }).then(data => {
|
||||
dialog.close();
|
||||
navigateTo(`dashboard/${data.slug}?edit`);
|
||||
navigateTo(`${data.url}?edit`);
|
||||
});
|
||||
recordEvent("create", "dashboard");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import Divider from "antd/lib/divider";
|
||||
import Link from "@/components/Link";
|
||||
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import notification from "@/services/notification";
|
||||
@@ -40,11 +41,30 @@ function TextboxDialog({ dialog, isNew, ...props }) {
|
||||
});
|
||||
}, [dialog, isNew, text]);
|
||||
|
||||
const confirmDialogDismiss = useCallback(() => {
|
||||
const originalText = props.text;
|
||||
if (text !== originalText) {
|
||||
Modal.confirm({
|
||||
title: "Quit editing?",
|
||||
content: "Changes you made so far will not be saved. Are you sure?",
|
||||
okText: "Yes, quit",
|
||||
okType: "danger",
|
||||
onOk: () => dialog.dismiss(),
|
||||
maskClosable: true,
|
||||
autoFocusButton: null,
|
||||
style: { top: 170 },
|
||||
});
|
||||
} else {
|
||||
dialog.dismiss();
|
||||
}
|
||||
}, [dialog, text, props.text]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...dialog.props}
|
||||
title={isNew ? "Add Textbox" : "Edit Textbox"}
|
||||
onOk={saveWidget}
|
||||
onCancel={confirmDialogDismiss}
|
||||
okText={isNew ? "Add to Dashboard" : "Save"}
|
||||
width={500}
|
||||
wrapProps={{ "data-test": "TextboxDialog" }}>
|
||||
@@ -59,9 +79,12 @@ function TextboxDialog({ dialog, isNew, ...props }) {
|
||||
/>
|
||||
<small>
|
||||
Supports basic{" "}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.markdownguide.org/cheat-sheet/#basic-syntax">
|
||||
<Tooltip title="Markdown guide opens in new window">Markdown</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</small>
|
||||
{text && (
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
> .filters-wrapper {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,15 +113,36 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.counter-visualization-content {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
bottom: 15px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
.counter-visualization-container {
|
||||
height: 100%;
|
||||
|
||||
.counter-visualization-content {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
bottom: 15px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-fixed-layout {
|
||||
.visualization-renderer > .visualization-renderer-wrapper {
|
||||
.counter-visualization-container {
|
||||
// counter is too large on Query pages, so let's add some constraints
|
||||
max-width: 600px;
|
||||
max-height: 400px;
|
||||
// center it
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import HtmlContent from "@redash/viz/lib/components/HtmlContent";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import Link from "@/components/Link";
|
||||
import Parameters from "@/components/Parameters";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import Timer from "@/components/Timer";
|
||||
@@ -30,27 +31,27 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
||||
return compact([
|
||||
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
||||
<Link href={downloadLink("csv")} download={downloadName("csv")} target="_self">
|
||||
Download as CSV File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as CSV File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="download_tsv" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
||||
<Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self">
|
||||
Download as TSV File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as TSV File"
|
||||
)}
|
||||
</Menu.Item>,
|
||||
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
|
||||
{!isQueryResultEmpty ? (
|
||||
<a href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
||||
<Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self">
|
||||
Download as Excel File
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
"Download as Excel File"
|
||||
)}
|
||||
@@ -58,7 +59,7 @@ function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParameters
|
||||
(canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
|
||||
canViewQuery && (
|
||||
<Menu.Item key="view_query">
|
||||
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a>
|
||||
<Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link>
|
||||
</Menu.Item>
|
||||
),
|
||||
canEditParameters && (
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import React from "react";
|
||||
import React, { useState, useReducer, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import Form from "antd/lib/form";
|
||||
import Input from "antd/lib/input";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Button from "antd/lib/button";
|
||||
import Upload from "antd/lib/upload";
|
||||
import Icon from "antd/lib/icon";
|
||||
import { includes, isFunction, filter, difference, isEmpty, some, isNumber, isBoolean } from "lodash";
|
||||
import Select from "antd/lib/select";
|
||||
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
|
||||
import notification from "@/services/notification";
|
||||
import Collapse from "@/components/Collapse";
|
||||
import AceEditorInput from "@/components/AceEditorInput";
|
||||
import { toHuman } from "@/lib/utils";
|
||||
import { Field, Action, AntdForm } from "../proptypes";
|
||||
import DynamicFormField, { FieldType } from "./DynamicFormField";
|
||||
import getFieldLabel from "./getFieldLabel";
|
||||
import helper from "./dynamicFormHelper";
|
||||
|
||||
import "./DynamicForm.less";
|
||||
|
||||
const ActionType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
callback: PropTypes.func.isRequired,
|
||||
type: PropTypes.string,
|
||||
pullRight: PropTypes.bool,
|
||||
disabledWhenDirty: PropTypes.bool,
|
||||
});
|
||||
|
||||
const AntdFormType = PropTypes.shape({
|
||||
validateFieldsAndScroll: PropTypes.func,
|
||||
});
|
||||
|
||||
const fieldRules = ({ type, required, minLength }) => {
|
||||
const requiredRule = required;
|
||||
const minLengthRule = minLength && includes(["text", "email", "password"], type);
|
||||
@@ -31,290 +36,206 @@ const fieldRules = ({ type, required, minLength }) => {
|
||||
].filter(rule => rule);
|
||||
};
|
||||
|
||||
class DynamicForm extends React.Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string,
|
||||
fields: PropTypes.arrayOf(Field),
|
||||
actions: PropTypes.arrayOf(Action),
|
||||
feedbackIcons: PropTypes.bool,
|
||||
hideSubmitButton: PropTypes.bool,
|
||||
saveText: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
form: AntdForm.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
id: null,
|
||||
fields: [],
|
||||
actions: [],
|
||||
feedbackIcons: false,
|
||||
hideSubmitButton: false,
|
||||
saveText: "Save",
|
||||
onSubmit: () => {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const hasFilledExtraField = some(props.fields, field => {
|
||||
const { extra, initialValue, placeholder } = field;
|
||||
return (
|
||||
extra &&
|
||||
(!isEmpty(initialValue) ||
|
||||
isNumber(initialValue) ||
|
||||
(isBoolean(initialValue) && initialValue.toString() !== placeholder))
|
||||
);
|
||||
});
|
||||
|
||||
const inProgressActions = {};
|
||||
props.actions.forEach(action => (inProgressActions[action.name] = false));
|
||||
|
||||
this.state = {
|
||||
isSubmitting: false,
|
||||
showExtraFields: hasFilledExtraField,
|
||||
inProgressActions,
|
||||
};
|
||||
|
||||
this.actionCallbacks = this.props.actions.reduce(
|
||||
(acc, cur) => ({
|
||||
...acc,
|
||||
[cur.name]: cur.callback,
|
||||
}),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
setActionInProgress = (actionName, inProgress) => {
|
||||
this.setState(prevState => ({
|
||||
inProgressActions: {
|
||||
...prevState.inProgressActions,
|
||||
[actionName]: inProgress,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
handleSubmit = e => {
|
||||
this.setState({ isSubmitting: true });
|
||||
e.preventDefault();
|
||||
|
||||
this.props.form.validateFieldsAndScroll((err, values) => {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const initialValue = this.props.fields.find(f => f.name === key).initialValue;
|
||||
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
||||
values[key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (!err) {
|
||||
this.props.onSubmit(
|
||||
values,
|
||||
msg => {
|
||||
const { setFieldsValue, getFieldsValue } = this.props.form;
|
||||
this.setState({ isSubmitting: false });
|
||||
setFieldsValue(getFieldsValue()); // reset form touched state
|
||||
notification.success(msg);
|
||||
},
|
||||
msg => {
|
||||
this.setState({ isSubmitting: false });
|
||||
notification.error(msg);
|
||||
}
|
||||
);
|
||||
} else this.setState({ isSubmitting: false });
|
||||
});
|
||||
};
|
||||
|
||||
handleAction = e => {
|
||||
const actionName = e.target.dataset.action;
|
||||
|
||||
this.setActionInProgress(actionName, true);
|
||||
this.actionCallbacks[actionName](() => {
|
||||
this.setActionInProgress(actionName, false);
|
||||
});
|
||||
};
|
||||
|
||||
base64File = (fieldName, e) => {
|
||||
if (e && e.fileList[0]) {
|
||||
helper.getBase64(e.file).then(value => {
|
||||
this.props.form.setFieldsValue({ [fieldName]: value });
|
||||
});
|
||||
function normalizeEmptyValuesToNull(fields, values) {
|
||||
return mapValues(values, (value, key) => {
|
||||
const { initialValue } = find(fields, { name: key }) || {};
|
||||
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
renderUpload(field, props) {
|
||||
const { getFieldDecorator, getFieldValue } = this.props.form;
|
||||
const { name, initialValue } = field;
|
||||
function DynamicFormFields({ fields, feedbackIcons, form }) {
|
||||
return fields.map(field => {
|
||||
const { name, type, initialValue, contentAfter } = field;
|
||||
const fieldLabel = getFieldLabel(field);
|
||||
|
||||
const fileOptions = {
|
||||
rules: fieldRules(field),
|
||||
initialValue,
|
||||
getValueFromEvent: this.base64File.bind(this, name),
|
||||
};
|
||||
|
||||
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
||||
|
||||
const upload = (
|
||||
<Upload {...props} beforeUpload={() => false}>
|
||||
<Button disabled={disabled}>
|
||||
<Icon type="upload" /> Click to upload
|
||||
</Button>
|
||||
</Upload>
|
||||
);
|
||||
|
||||
return getFieldDecorator(name, fileOptions)(upload);
|
||||
}
|
||||
|
||||
renderSelect(field, props) {
|
||||
const { getFieldDecorator } = this.props.form;
|
||||
const { name, options, mode, initialValue, readOnly, loading } = field;
|
||||
const { Option } = Select;
|
||||
|
||||
const decoratorOptions = {
|
||||
rules: fieldRules(field),
|
||||
initialValue,
|
||||
};
|
||||
|
||||
return getFieldDecorator(
|
||||
const formItemProps = {
|
||||
name,
|
||||
decoratorOptions
|
||||
)(
|
||||
<Select
|
||||
{...props}
|
||||
optionFilterProp="children"
|
||||
loading={loading || false}
|
||||
mode={mode}
|
||||
getPopupContainer={trigger => trigger.parentNode}>
|
||||
{options &&
|
||||
options.map(option => (
|
||||
<Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
||||
{option.name || option.value}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(field, props) {
|
||||
const { getFieldDecorator } = this.props.form;
|
||||
const { name, type, initialValue } = field;
|
||||
const fieldLabel = field.title || toHuman(name);
|
||||
|
||||
const options = {
|
||||
className: "m-b-10",
|
||||
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
||||
label: type === "checkbox" ? "" : fieldLabel,
|
||||
rules: fieldRules(field),
|
||||
valuePropName: type === "checkbox" ? "checked" : "value",
|
||||
initialValue,
|
||||
};
|
||||
|
||||
if (type === "checkbox") {
|
||||
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
|
||||
} else if (type === "file") {
|
||||
return this.renderUpload(field, props);
|
||||
} else if (type === "select") {
|
||||
return this.renderSelect(field, props);
|
||||
} else if (type === "content") {
|
||||
return field.content;
|
||||
} else if (type === "number") {
|
||||
return getFieldDecorator(name, options)(<InputNumber {...props} />);
|
||||
} else if (type === "textarea") {
|
||||
return getFieldDecorator(name, options)(<Input.TextArea {...props} />);
|
||||
} else if (type === "ace") {
|
||||
return getFieldDecorator(name, options)(<AceEditorInput {...props} />);
|
||||
if (type === "file") {
|
||||
formItemProps.valuePropName = "data-value";
|
||||
formItemProps.getValueFromEvent = e => {
|
||||
if (e && e.fileList[0]) {
|
||||
helper.getBase64(e.file).then(value => {
|
||||
form.setFieldsValue({ [name]: value });
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
return getFieldDecorator(name, options)(<Input {...props} />);
|
||||
}
|
||||
|
||||
renderFields(fields) {
|
||||
return fields.map(field => {
|
||||
const FormItem = Form.Item;
|
||||
const { name, title, type, readOnly, autoFocus, contentAfter } = field;
|
||||
const fieldLabel = title || toHuman(name);
|
||||
const { feedbackIcons, form } = this.props;
|
||||
|
||||
const formItemProps = {
|
||||
className: "m-b-10",
|
||||
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
|
||||
label: type === "checkbox" ? "" : fieldLabel,
|
||||
};
|
||||
|
||||
const fieldProps = {
|
||||
...field.props,
|
||||
className: "w-100",
|
||||
name,
|
||||
type,
|
||||
readOnly,
|
||||
autoFocus,
|
||||
placeholder: field.placeholder,
|
||||
"data-test": fieldLabel,
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment key={name}>
|
||||
<FormItem {...formItemProps}>{this.renderField(field, fieldProps)}</FormItem>
|
||||
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderActions() {
|
||||
return this.props.actions.map(action => {
|
||||
const inProgress = this.state.inProgressActions[action.name];
|
||||
const { isFieldsTouched } = this.props.form;
|
||||
|
||||
const actionProps = {
|
||||
key: action.name,
|
||||
htmlType: "button",
|
||||
className: action.pullRight ? "pull-right m-t-10" : "m-t-10",
|
||||
type: action.type,
|
||||
disabled: isFieldsTouched() && action.disableWhenDirty,
|
||||
loading: inProgress,
|
||||
onClick: this.handleAction,
|
||||
};
|
||||
|
||||
return (
|
||||
<Button {...actionProps} data-action={action.name}>
|
||||
{action.name}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const submitProps = {
|
||||
type: "primary",
|
||||
htmlType: "submit",
|
||||
className: "w-100 m-t-20",
|
||||
disabled: this.state.isSubmitting,
|
||||
loading: this.state.isSubmitting,
|
||||
};
|
||||
const { id, hideSubmitButton, saveText, fields } = this.props;
|
||||
const { showExtraFields } = this.state;
|
||||
const saveButton = !hideSubmitButton;
|
||||
const extraFields = filter(fields, { extra: true });
|
||||
const regularFields = difference(fields, extraFields);
|
||||
|
||||
return (
|
||||
<Form id={id} className="dynamic-form" layout="vertical" onSubmit={this.handleSubmit}>
|
||||
{this.renderFields(regularFields)}
|
||||
{!isEmpty(extraFields) && (
|
||||
<div className="extra-options">
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
className="extra-options-button"
|
||||
onClick={() => this.setState({ showExtraFields: !showExtraFields })}>
|
||||
Additional Settings
|
||||
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
||||
</Button>
|
||||
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
||||
{this.renderFields(extraFields)}
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{saveButton && <Button {...submitProps}>{saveText}</Button>}
|
||||
{this.renderActions()}
|
||||
</Form>
|
||||
<React.Fragment key={name}>
|
||||
<Form.Item {...formItemProps}>
|
||||
<DynamicFormField field={field} form={form} />
|
||||
</Form.Item>
|
||||
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Form.create()(DynamicForm);
|
||||
DynamicFormFields.propTypes = {
|
||||
fields: PropTypes.arrayOf(FieldType),
|
||||
feedbackIcons: PropTypes.bool,
|
||||
form: AntdFormType.isRequired,
|
||||
};
|
||||
|
||||
DynamicFormFields.defaultProps = {
|
||||
fields: [],
|
||||
feedbackIcons: false,
|
||||
};
|
||||
|
||||
const reducerForActionSet = (state, action) => {
|
||||
if (action.inProgress) {
|
||||
state.add(action.actionName);
|
||||
} else {
|
||||
state.delete(action.actionName);
|
||||
}
|
||||
return new Set(state);
|
||||
};
|
||||
|
||||
function DynamicFormActions({ actions, isFormDirty }) {
|
||||
const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set());
|
||||
|
||||
const handleAction = useCallback(action => {
|
||||
const actionName = action.name;
|
||||
if (isFunction(action.callback)) {
|
||||
setActionInProgress({ actionName, inProgress: true });
|
||||
action.callback(() => {
|
||||
setActionInProgress({ actionName, inProgress: false });
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
return actions.map(action => (
|
||||
<Button
|
||||
key={action.name}
|
||||
htmlType="button"
|
||||
className={cx("m-t-10", { "pull-right": action.pullRight })}
|
||||
type={action.type}
|
||||
disabled={isFormDirty && action.disableWhenDirty}
|
||||
loading={inProgressActions.has(action.name)}
|
||||
onClick={() => handleAction(action)}>
|
||||
{action.name}
|
||||
</Button>
|
||||
));
|
||||
}
|
||||
|
||||
DynamicFormActions.propTypes = {
|
||||
actions: PropTypes.arrayOf(ActionType),
|
||||
isFormDirty: PropTypes.bool,
|
||||
};
|
||||
|
||||
DynamicFormActions.defaultProps = {
|
||||
actions: [],
|
||||
isFormDirty: false,
|
||||
};
|
||||
|
||||
export default function DynamicForm({
|
||||
id,
|
||||
fields,
|
||||
actions,
|
||||
feedbackIcons,
|
||||
hideSubmitButton,
|
||||
defaultShowExtraFields,
|
||||
saveText,
|
||||
onSubmit,
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields);
|
||||
const [form] = Form.useForm();
|
||||
const extraFields = filter(fields, { extra: true });
|
||||
const regularFields = difference(fields, extraFields);
|
||||
|
||||
const handleFinish = useCallback(
|
||||
values => {
|
||||
setIsSubmitting(true);
|
||||
values = normalizeEmptyValuesToNull(fields, values);
|
||||
onSubmit(
|
||||
values,
|
||||
msg => {
|
||||
const { setFieldsValue, getFieldsValue } = form;
|
||||
setIsSubmitting(false);
|
||||
setFieldsValue(getFieldsValue()); // reset form touched state
|
||||
notification.success(msg);
|
||||
},
|
||||
msg => {
|
||||
setIsSubmitting(false);
|
||||
notification.error(msg);
|
||||
}
|
||||
);
|
||||
},
|
||||
[form, fields, onSubmit]
|
||||
);
|
||||
|
||||
const handleFinishFailed = useCallback(
|
||||
({ errorFields }) => {
|
||||
form.scrollToField(errorFields[0].name);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
id={id}
|
||||
className="dynamic-form"
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
onFinishFailed={handleFinishFailed}>
|
||||
<DynamicFormFields fields={regularFields} feedbackIcons={feedbackIcons} form={form} />
|
||||
{!isEmpty(extraFields) && (
|
||||
<div className="extra-options">
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
className="extra-options-button"
|
||||
onClick={() => setShowExtraFields(currentShowExtraFields => !currentShowExtraFields)}>
|
||||
Additional Settings
|
||||
<i className={cx("fa m-l-5", { "fa-caret-up": showExtraFields, "fa-caret-down": !showExtraFields })} />
|
||||
</Button>
|
||||
<Collapse collapsed={!showExtraFields} className="extra-options-content">
|
||||
<DynamicFormFields fields={extraFields} feedbackIcons={feedbackIcons} form={form} />
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{!hideSubmitButton && (
|
||||
<Button className="w-100 m-t-20" type="primary" htmlType="submit" disabled={isSubmitting}>
|
||||
{saveText}
|
||||
</Button>
|
||||
)}
|
||||
<DynamicFormActions actions={actions} isFormDirty={form.isFieldsTouched()} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
DynamicForm.propTypes = {
|
||||
id: PropTypes.string,
|
||||
fields: PropTypes.arrayOf(FieldType),
|
||||
actions: PropTypes.arrayOf(ActionType),
|
||||
feedbackIcons: PropTypes.bool,
|
||||
hideSubmitButton: PropTypes.bool,
|
||||
defaultShowExtraFields: PropTypes.bool,
|
||||
saveText: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
};
|
||||
|
||||
DynamicForm.defaultProps = {
|
||||
id: null,
|
||||
fields: [],
|
||||
actions: [],
|
||||
feedbackIcons: false,
|
||||
hideSubmitButton: false,
|
||||
defaultShowExtraFields: false,
|
||||
saveText: "Save",
|
||||
onSubmit: () => {},
|
||||
};
|
||||
|
||||
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
82
client/app/components/dynamic-form/DynamicFormField.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { get } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import getFieldLabel from "./getFieldLabel";
|
||||
|
||||
import {
|
||||
AceEditorField,
|
||||
CheckboxField,
|
||||
ContentField,
|
||||
FileField,
|
||||
InputField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
TextAreaField,
|
||||
} from "./fields";
|
||||
|
||||
export const FieldType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.oneOf([
|
||||
"ace",
|
||||
"text",
|
||||
"textarea",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"checkbox",
|
||||
"file",
|
||||
"select",
|
||||
"content",
|
||||
]).isRequired,
|
||||
initialValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
PropTypes.arrayOf(PropTypes.number),
|
||||
]),
|
||||
content: PropTypes.node,
|
||||
mode: PropTypes.string,
|
||||
required: PropTypes.bool,
|
||||
extra: PropTypes.bool,
|
||||
readOnly: PropTypes.bool,
|
||||
autoFocus: PropTypes.bool,
|
||||
minLength: PropTypes.number,
|
||||
placeholder: PropTypes.string,
|
||||
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
loading: PropTypes.bool,
|
||||
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
});
|
||||
|
||||
const FieldTypeComponent = {
|
||||
checkbox: CheckboxField,
|
||||
file: FileField,
|
||||
select: SelectField,
|
||||
number: NumberField,
|
||||
textarea: TextAreaField,
|
||||
ace: AceEditorField,
|
||||
content: ContentField,
|
||||
};
|
||||
|
||||
export default function DynamicFormField({ form, field, ...otherProps }) {
|
||||
const { name, type, readOnly, autoFocus } = field;
|
||||
const fieldLabel = getFieldLabel(field);
|
||||
|
||||
const fieldProps = {
|
||||
...field.props,
|
||||
className: "w-100",
|
||||
name,
|
||||
type,
|
||||
readOnly,
|
||||
autoFocus,
|
||||
placeholder: field.placeholder,
|
||||
"data-test": fieldLabel,
|
||||
...otherProps,
|
||||
};
|
||||
|
||||
const FieldComponent = get(FieldTypeComponent, type, InputField);
|
||||
return <FieldComponent {...fieldProps} form={form} field={field} />;
|
||||
}
|
||||
|
||||
DynamicFormField.propTypes = { field: FieldType.isRequired };
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { each, includes, isUndefined, isEmpty, isNil, map } from "lodash";
|
||||
import { each, includes, isUndefined, isEmpty, isNil, map, get, some } from "lodash";
|
||||
|
||||
function orderedInputs(properties, order, targetOptions) {
|
||||
const inputs = new Array(order.length);
|
||||
@@ -124,8 +124,18 @@ function getBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
function hasFilledExtraField(type, target) {
|
||||
const extraOptions = get(type, "configuration_schema.extra_options", []);
|
||||
return some(extraOptions, optionName => {
|
||||
const defaultOptionValue = get(type, ["configuration_schema", "properties", optionName, "default"]);
|
||||
const targetOptionValue = get(target, ["options", optionName]);
|
||||
return !isNil(targetOptionValue) && targetOptionValue !== defaultOptionValue;
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getFields,
|
||||
updateTargetWithValues,
|
||||
getBase64,
|
||||
hasFilledExtraField,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import AceEditorInput from "@/components/AceEditorInput";
|
||||
|
||||
export default function AceEditorField({ form, field, ...otherProps }) {
|
||||
return <AceEditorInput {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import getFieldLabel from "../getFieldLabel";
|
||||
|
||||
export default function CheckboxField({ form, field, ...otherProps }) {
|
||||
const fieldLabel = getFieldLabel(field);
|
||||
return <Checkbox {...otherProps}>{fieldLabel}</Checkbox>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ContentField({ field }) {
|
||||
return field.content;
|
||||
}
|
||||
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
18
client/app/components/dynamic-form/fields/FileField.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import Button from "antd/lib/button";
|
||||
import Upload from "antd/lib/upload";
|
||||
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
|
||||
|
||||
export default function FileField({ form, field, ...otherProps }) {
|
||||
const { name, initialValue } = field;
|
||||
const { getFieldValue } = form;
|
||||
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
|
||||
|
||||
return (
|
||||
<Upload {...otherProps} beforeUpload={() => false}>
|
||||
<Button disabled={disabled}>
|
||||
<UploadOutlinedIcon /> Click to upload
|
||||
</Button>
|
||||
</Upload>
|
||||
);
|
||||
}
|
||||
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
6
client/app/components/dynamic-form/fields/InputField.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import Input from "antd/lib/input";
|
||||
|
||||
export default function InputField({ form, field, ...otherProps }) {
|
||||
return <Input {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import InputNumber from "antd/lib/input-number";
|
||||
|
||||
export default function NumberField({ form, field, ...otherProps }) {
|
||||
return <InputNumber {...otherProps} />;
|
||||
}
|
||||
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
21
client/app/components/dynamic-form/fields/SelectField.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import Select from "antd/lib/select";
|
||||
|
||||
export default function SelectField({ form, field, ...otherProps }) {
|
||||
const { readOnly } = field;
|
||||
return (
|
||||
<Select
|
||||
{...otherProps}
|
||||
optionFilterProp="children"
|
||||
loading={field.loading || false}
|
||||
mode={field.mode}
|
||||
getPopupContainer={trigger => trigger.parentNode}>
|
||||
{field.options &&
|
||||
field.options.map(option => (
|
||||
<Select.Option key={`${option.value}`} value={option.value} disabled={readOnly}>
|
||||
{option.name || option.value}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import Input from "antd/lib/input";
|
||||
|
||||
export default function TextAreaField({ form, field, ...otherProps }) {
|
||||
return <Input.TextArea {...otherProps} />;
|
||||
}
|
||||
8
client/app/components/dynamic-form/fields/index.js
Normal file
8
client/app/components/dynamic-form/fields/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as AceEditorField } from "./AceEditorField";
|
||||
export { default as CheckboxField } from "./CheckboxField";
|
||||
export { default as ContentField } from "./ContentField";
|
||||
export { default as FileField } from "./FileField";
|
||||
export { default as InputField } from "./InputField";
|
||||
export { default as NumberField } from "./NumberField";
|
||||
export { default as SelectField } from "./SelectField";
|
||||
export { default as TextAreaField } from "./TextAreaField";
|
||||
6
client/app/components/dynamic-form/getFieldLabel.js
Normal file
6
client/app/components/dynamic-form/getFieldLabel.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { toHuman } from "@/lib/utils";
|
||||
|
||||
export default function getFieldLabel(field) {
|
||||
const { title, name } = field;
|
||||
return title || toHuman(name);
|
||||
}
|
||||
@@ -93,20 +93,21 @@ class DateParameter extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<DateComponent
|
||||
ref={this.dateComponentRef}
|
||||
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
suffixIcon={
|
||||
<DynamicButton
|
||||
options={DYNAMIC_DATE_OPTIONS}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
<div className="date-parameter">
|
||||
<DateComponent
|
||||
ref={this.dateComponentRef}
|
||||
className={classNames("redash-datepicker", { "dynamic-value": hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
suffixIcon={null}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
<DynamicButton
|
||||
options={DYNAMIC_DATE_OPTIONS}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ const DYNAMIC_DATE_OPTIONS = [
|
||||
.value()[0]
|
||||
.format("MMM D") + " - Today",
|
||||
},
|
||||
{
|
||||
name: "Last 12 months",
|
||||
value: getDynamicDateRangeFromString("d_last_12_months"),
|
||||
label: null,
|
||||
},
|
||||
];
|
||||
|
||||
const DYNAMIC_DATETIME_OPTIONS = [
|
||||
@@ -203,21 +208,22 @@ class DateRangeParameter extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<DateRangeComponent
|
||||
ref={this.dateRangeComponentRef}
|
||||
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
|
||||
suffixIcon={
|
||||
<DynamicButton
|
||||
options={options}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
<div className="data-range-parameter">
|
||||
<DateRangeComponent
|
||||
ref={this.dateRangeComponentRef}
|
||||
className={classNames("redash-datepicker date-range-input", { "dynamic-value": hasDynamicValue }, className)}
|
||||
onSelect={onSelect}
|
||||
style={{ width: hasDynamicValue ? 195 : widthByType[type] }}
|
||||
suffixIcon={null}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
<DynamicButton
|
||||
options={options}
|
||||
selectedDynamicValue={hasDynamicValue ? value : null}
|
||||
enabled={hasDynamicValue}
|
||||
onSelect={this.onDynamicValueSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { isFunction, get, findIndex } from "lodash";
|
||||
import Dropdown from "antd/lib/dropdown";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Menu from "antd/lib/menu";
|
||||
import Typography from "antd/lib/typography";
|
||||
import { DynamicDateType } from "@/services/parameters/DateParameter";
|
||||
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
|
||||
|
||||
import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined";
|
||||
import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone";
|
||||
import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined";
|
||||
|
||||
import "./DynamicButton.less";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -28,7 +31,7 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
{enabled && <Menu.Divider />}
|
||||
{enabled && (
|
||||
<Menu.Item>
|
||||
<Icon type="arrow-left" />
|
||||
<ArrowLeftOutlinedIcon />
|
||||
<Text type="secondary">Back to Static Value</Text>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -45,7 +48,13 @@ function DynamicButton({ options, selectedDynamicValue, onSelect, enabled }) {
|
||||
className="dynamic-button"
|
||||
placement="bottomRight"
|
||||
trigger={["click"]}
|
||||
icon={<Icon type="thunderbolt" theme={enabled ? "twoTone" : "outlined"} className="dynamic-icon" />}
|
||||
icon={
|
||||
enabled ? (
|
||||
<ThunderboltTwoToneIcon className="dynamic-icon" />
|
||||
) : (
|
||||
<ThunderboltOutlinedIcon className="dynamic-icon" />
|
||||
)
|
||||
}
|
||||
getPopupContainer={() => containerRef.current}
|
||||
data-test="DynamicButton"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@import '../../assets/less/inc/variables';
|
||||
@import "../../assets/less/inc/variables";
|
||||
|
||||
.redash-datepicker {
|
||||
.ant-calendar-picker-clear {
|
||||
right: 35px;
|
||||
padding-right: 35px !important;
|
||||
|
||||
&.ant-picker-range .ant-picker-clear {
|
||||
right: 35px !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -14,17 +16,19 @@
|
||||
& ::placeholder {
|
||||
color: @text-color !important;
|
||||
}
|
||||
|
||||
|
||||
&.date-range-input {
|
||||
.ant-calendar-range-picker-input {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
.ant-picker-active-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-calendar-range-picker-separator,
|
||||
.ant-calendar-range-picker-input:not(:first-child) {
|
||||
|
||||
.ant-picker-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-picker-input:not(:first-child) {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
client/app/components/empty-state/EmptyState.d.ts
vendored
Normal file
41
client/app/components/empty-state/EmptyState.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users";
|
||||
export type StepKey<K> = DefaultStepKey | K;
|
||||
|
||||
export interface StepItem<K> {
|
||||
key: StepKey<K>;
|
||||
node: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps<K = unknown> {
|
||||
header?: string;
|
||||
icon?: string;
|
||||
description: string;
|
||||
illustration: string;
|
||||
illustrationPath?: string;
|
||||
helpLink: string;
|
||||
|
||||
onboardingMode?: boolean;
|
||||
showAlertStep?: boolean;
|
||||
showDashboardStep?: boolean;
|
||||
showDataSourceStep?: boolean;
|
||||
showInviteStep?: boolean;
|
||||
|
||||
getStepsItems?: (items: Array<StepItem<K>>) => Array<StepItem<K>>;
|
||||
}
|
||||
|
||||
declare class EmptyState<R> extends React.Component<EmptyStateProps<R>> {}
|
||||
|
||||
export default EmptyState;
|
||||
|
||||
export interface StepProps {
|
||||
show: boolean;
|
||||
completed: boolean;
|
||||
url?: string;
|
||||
urlText?: string;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export declare const Step: React.FunctionComponent<StepProps>;
|
||||
@@ -2,21 +2,22 @@ import { keys, some } from "lodash";
|
||||
import React, { useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Link from "@/components/Link";
|
||||
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
|
||||
import { currentUser } from "@/services/auth";
|
||||
import organizationStatus from "@/services/organizationStatus";
|
||||
import "./empty-state.less";
|
||||
|
||||
function Step({ show, completed, text, url, urlText, onClick }) {
|
||||
export function Step({ show, completed, text, url, urlText, onClick }) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classNames({ done: completed })}>
|
||||
<a href={url} onClick={onClick}>
|
||||
<Link href={url} onClick={onClick}>
|
||||
{urlText}
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
{text}
|
||||
</li>
|
||||
);
|
||||
@@ -46,10 +47,13 @@ function EmptyState({
|
||||
onboardingMode,
|
||||
showAlertStep,
|
||||
showDashboardStep,
|
||||
showDataSourceStep,
|
||||
showInviteStep,
|
||||
getStepsItems,
|
||||
illustrationPath,
|
||||
}) {
|
||||
const isAvailable = {
|
||||
dataSource: true,
|
||||
dataSource: showDataSourceStep,
|
||||
query: true,
|
||||
alert: showAlertStep,
|
||||
dashboard: showDashboardStep,
|
||||
@@ -75,6 +79,92 @@ function EmptyState({
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderDataSourcesStep = () => {
|
||||
if (currentUser.isAdmin) {
|
||||
return (
|
||||
<Step
|
||||
key="dataSources"
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
url="data_sources/new"
|
||||
urlText="Connect"
|
||||
text="a Data Source"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Step
|
||||
key="dataSources"
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
text="Ask an account admin to connect a data source"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultStepsItems = [
|
||||
{
|
||||
key: "dataSources",
|
||||
node: renderDataSourcesStep(),
|
||||
},
|
||||
{
|
||||
key: "queries",
|
||||
node: (
|
||||
<Step
|
||||
key="queries"
|
||||
show={isAvailable.query}
|
||||
completed={isCompleted.query}
|
||||
url="queries/new"
|
||||
urlText="Create"
|
||||
text="your first Query"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "alerts",
|
||||
node: (
|
||||
<Step
|
||||
key="alerts"
|
||||
show={isAvailable.alert}
|
||||
completed={isCompleted.alert}
|
||||
url="alerts/new"
|
||||
urlText="Create"
|
||||
text="your first Alert"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "dashboards",
|
||||
node: (
|
||||
<Step
|
||||
key="dashboards"
|
||||
show={isAvailable.dashboard}
|
||||
completed={isCompleted.dashboard}
|
||||
onClick={showCreateDashboardDialog}
|
||||
urlText="Create"
|
||||
text="your first Dashboard"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
node: (
|
||||
<Step
|
||||
key="users"
|
||||
show={isAvailable.inviteUsers}
|
||||
completed={isCompleted.inviteUsers}
|
||||
url="users/new"
|
||||
urlText="Invite"
|
||||
text="your team members"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
|
||||
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg";
|
||||
|
||||
return (
|
||||
<div className="empty-state bg-white tiled">
|
||||
<div className="empty-state__summary">
|
||||
@@ -83,66 +173,17 @@ function EmptyState({
|
||||
<i className={icon} />
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
<img
|
||||
src={"/static/images/illustrations/" + illustration + ".svg"}
|
||||
alt={illustration + " Illustration"}
|
||||
width="75%"
|
||||
/>
|
||||
<img src={imageSource} alt={illustration + " Illustration"} width="75%" />
|
||||
</div>
|
||||
<div className="empty-state__steps">
|
||||
<h4>Let's get started</h4>
|
||||
<ol>
|
||||
{currentUser.isAdmin && (
|
||||
<Step
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
url="data_sources/new"
|
||||
urlText="Connect"
|
||||
text="a Data Source"
|
||||
/>
|
||||
)}
|
||||
{!currentUser.isAdmin && (
|
||||
<Step
|
||||
show={isAvailable.dataSource}
|
||||
completed={isCompleted.dataSource}
|
||||
text="Ask an account admin to connect a data source"
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
show={isAvailable.query}
|
||||
completed={isCompleted.query}
|
||||
url="queries/new"
|
||||
urlText="Create"
|
||||
text="your first Query"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.alert}
|
||||
completed={isCompleted.alert}
|
||||
url="alerts/new"
|
||||
urlText="Create"
|
||||
text="your first Alert"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.dashboard}
|
||||
completed={isCompleted.dashboard}
|
||||
onClick={showCreateDashboardDialog}
|
||||
urlText="Create"
|
||||
text="your first Dashboard"
|
||||
/>
|
||||
<Step
|
||||
show={isAvailable.inviteUsers}
|
||||
completed={isCompleted.inviteUsers}
|
||||
url="users/new"
|
||||
urlText="Invite"
|
||||
text="your team members"
|
||||
/>
|
||||
</ol>
|
||||
<ol>{stepsItems.map(item => item.node)}</ol>
|
||||
<p>
|
||||
Need more support?{" "}
|
||||
<a href={helpLink} target="_blank" rel="noopener noreferrer">
|
||||
<Link href={helpLink} target="_blank" rel="noopener noreferrer">
|
||||
See our Help
|
||||
<i className="fa fa-external-link m-l-5" aria-hidden="true" />
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,12 +195,16 @@ EmptyState.propTypes = {
|
||||
header: PropTypes.string,
|
||||
description: PropTypes.string.isRequired,
|
||||
illustration: PropTypes.string.isRequired,
|
||||
illustrationPath: PropTypes.string,
|
||||
helpLink: PropTypes.string.isRequired,
|
||||
|
||||
onboardingMode: PropTypes.bool,
|
||||
showAlertStep: PropTypes.bool,
|
||||
showDashboardStep: PropTypes.bool,
|
||||
showDataSourceStep: PropTypes.bool,
|
||||
showInviteStep: PropTypes.bool,
|
||||
|
||||
getStepItems: PropTypes.func,
|
||||
};
|
||||
|
||||
EmptyState.defaultProps = {
|
||||
@@ -169,6 +214,7 @@ EmptyState.defaultProps = {
|
||||
onboardingMode: false,
|
||||
showAlertStep: false,
|
||||
showDashboardStep: false,
|
||||
showDataSourceStep: true,
|
||||
showInviteStep: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -24,12 +24,6 @@ export default function DetailsPageSidebar({
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Sidebar.Menu items={items} selected={controller.params.currentPage} />
|
||||
<Sidebar.PageSizeSelect
|
||||
className="m-b-10"
|
||||
options={controller.pageSizeOptions}
|
||||
value={controller.itemsPerPage}
|
||||
onChange={itemsPerPage => controller.updatePagination({ itemsPerPage })}
|
||||
/>
|
||||
{canAddMembers && (
|
||||
<Button className="w-100 m-t-5" type="primary" onClick={onAddMembersClick}>
|
||||
<i className="fa fa-plus m-r-5" />
|
||||
|
||||
@@ -3,6 +3,42 @@ import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import hoistNonReactStatics from "hoist-non-react-statics";
|
||||
import { clientConfig } from "@/services/auth";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
export interface PaginationOptions {
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export interface Controller<I, P = any> {
|
||||
params: P; // TODO: Find out what params is (except merging with props)
|
||||
|
||||
isLoaded: boolean;
|
||||
isEmpty: boolean;
|
||||
|
||||
// search
|
||||
searchTerm?: string;
|
||||
updateSearch: (searchTerm: string) => void;
|
||||
|
||||
// tags
|
||||
selectedTags: string[];
|
||||
updateSelectedTags: (selectedTags: string[]) => void;
|
||||
|
||||
// sorting
|
||||
orderByField?: string;
|
||||
orderByReverse: boolean;
|
||||
toggleSorting: (orderByField: string) => void;
|
||||
|
||||
// pagination
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
totalItemsCount: number;
|
||||
pageSizeOptions: number[];
|
||||
pageItems: I[];
|
||||
updatePagination: (options: PaginationOptions) => void; // ({ page: number, itemsPerPage: number }) => void
|
||||
|
||||
handleError: (error: any) => void; // TODO: Find out if error is string or object or Exception.
|
||||
}
|
||||
|
||||
export const ControllerType = PropTypes.shape({
|
||||
// values of props declared by wrapped component and some additional props from items list
|
||||
@@ -35,15 +71,40 @@ export const ControllerType = PropTypes.shape({
|
||||
handleError: PropTypes.func.isRequired, // (error) => void
|
||||
});
|
||||
|
||||
export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
class ItemsListWrapper extends React.Component {
|
||||
export type GenericItemSourceError = AxiosError | Error;
|
||||
|
||||
export interface ItemsListWrapperProps {
|
||||
onError?: (error: AxiosError | Error) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ItemsListWrapperState<I, P = any> extends Controller<I, P> {
|
||||
totalCount?: number;
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
type ItemsSource = any; // TODO: Type ItemsSource
|
||||
type StateStorage = any; // TODO: Type StateStore
|
||||
|
||||
export interface ItemsListWrappedComponentProps<I, P = any> {
|
||||
controller: Controller<I, P>;
|
||||
}
|
||||
|
||||
export function wrap<I, P = any>(
|
||||
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
|
||||
createItemsSource: () => ItemsSource,
|
||||
createStateStorage: () => StateStorage
|
||||
) {
|
||||
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
|
||||
private _itemsSource: ItemsSource;
|
||||
|
||||
static propTypes = {
|
||||
onError: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: error => {
|
||||
onError: (error: GenericItemSourceError) => {
|
||||
// Allow calling chain to roll up, and then throw the error in global context
|
||||
setTimeout(() => {
|
||||
throw error;
|
||||
@@ -52,7 +113,7 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
children: null,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: ItemsListWrapperProps) {
|
||||
super(props);
|
||||
|
||||
const stateStorage = createStateStorage();
|
||||
@@ -73,7 +134,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
this.setState(this.getState({ ...state, isLoaded: true }));
|
||||
};
|
||||
|
||||
itemsSource.onError = error => this.props.onError(error);
|
||||
itemsSource.onError = (error: GenericItemSourceError) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.props.onError!(error);
|
||||
|
||||
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
|
||||
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
|
||||
@@ -93,13 +156,22 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onBeforeUpdate = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onAfterUpdate = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
this._itemsSource.onError = () => {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
getState({ isLoaded, totalCount, pageItems, params, ...rest }) {
|
||||
getState({
|
||||
isLoaded,
|
||||
totalCount,
|
||||
pageItems,
|
||||
params,
|
||||
...rest
|
||||
}: ItemsListWrapperState<I, P>): ItemsListWrapperState<I, P> {
|
||||
return {
|
||||
...rest,
|
||||
|
||||
@@ -110,9 +182,9 @@ export function wrap(WrappedComponent, createItemsSource, createStateStorage) {
|
||||
|
||||
isLoaded,
|
||||
isEmpty: !isLoaded || totalCount === 0,
|
||||
totalItemsCount: isLoaded ? totalCount : 0,
|
||||
pageSizeOptions: clientConfig.pageSizeOptions,
|
||||
pageItems: isLoaded ? pageItems : [],
|
||||
totalItemsCount: totalCount || 0,
|
||||
pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js
|
||||
pageItems: pageItems || [],
|
||||
};
|
||||
}
|
||||
|
||||
51
client/app/components/items-list/classes/ItemsSource.d.ts
vendored
Normal file
51
client/app/components/items-list/classes/ItemsSource.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface ItemsSourceOptions<I = any> extends Partial<ItemsSourceState> {
|
||||
getRequest?: (params: any, context: any) => any; // TODO: Add stricter types
|
||||
doRequest?: () => any; // TODO: Add stricter type
|
||||
processResults?: () => any; // TODO: Add stricter type
|
||||
isPlainList?: boolean;
|
||||
sortByIteratees?: { [fieldName: string]: (a: I) => number };
|
||||
}
|
||||
|
||||
export interface GetResourceContext extends ItemsSourceState {
|
||||
params: {
|
||||
currentPage: number;
|
||||
// TODO: Add more context parameters
|
||||
};
|
||||
}
|
||||
|
||||
export type GetResourceRequest = any; // TODO: Add stricter type
|
||||
|
||||
export interface ItemsPage<INPUT = any> {
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
results: INPUT[];
|
||||
}
|
||||
|
||||
export interface ResourceItemsSourceOptions<INPUT = any, ITEM = any> extends ItemsSourceOptions {
|
||||
getResource: (context: GetResourceContext) => (request: GetResourceRequest) => Promise<INPUT[]>;
|
||||
getItemProcessor?: () => (input: INPUT) => ITEM;
|
||||
}
|
||||
|
||||
export type ItemsSourceState<ITEM = any> = {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
orderByField: string;
|
||||
orderByReverse: boolean;
|
||||
searchTerm: string;
|
||||
selectedTags: string[];
|
||||
totalCount: number;
|
||||
pageItems: ITEM[];
|
||||
allItems: ITEM[] | undefined;
|
||||
params: {
|
||||
pageTitle?: string;
|
||||
} & { [key: string]: string | number };
|
||||
};
|
||||
|
||||
declare class ItemsSource {
|
||||
constructor(options: ItemsSourceOptions);
|
||||
}
|
||||
|
||||
declare class ResourceItemsSource<I> {
|
||||
constructor(options: ResourceItemsSourceOptions<I>);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export class ItemsSource {
|
||||
|
||||
onError = null;
|
||||
|
||||
sortByIteratees = undefined;
|
||||
|
||||
getCallbackContext = () => null;
|
||||
|
||||
_beforeUpdate() {
|
||||
@@ -41,21 +43,34 @@ export class ItemsSource {
|
||||
extend(customParams, params);
|
||||
},
|
||||
};
|
||||
return this._beforeUpdate().then(() =>
|
||||
this._fetcher
|
||||
return this._beforeUpdate().then(() => {
|
||||
const fetchToken = Math.random()
|
||||
.toString(36)
|
||||
.substr(2);
|
||||
this._currentFetchToken = fetchToken;
|
||||
return this._fetcher
|
||||
.fetch(changes, state, context)
|
||||
.then(({ results, count, allResults }) => {
|
||||
this._pageItems = results;
|
||||
this._allItems = allResults || null;
|
||||
this._paginator.setTotalCount(count);
|
||||
this._params = { ...this._params, ...customParams };
|
||||
return this._afterUpdate();
|
||||
if (this._currentFetchToken === fetchToken) {
|
||||
this._pageItems = results;
|
||||
this._allItems = allResults || null;
|
||||
this._paginator.setTotalCount(count);
|
||||
this._params = { ...this._params, ...customParams };
|
||||
return this._afterUpdate();
|
||||
}
|
||||
})
|
||||
.catch(error => this.handleError(error))
|
||||
);
|
||||
.catch(error => this.handleError(error));
|
||||
});
|
||||
}
|
||||
|
||||
constructor({ getRequest, doRequest, processResults, isPlainList = false, ...defaultState }) {
|
||||
constructor({
|
||||
getRequest,
|
||||
doRequest,
|
||||
processResults,
|
||||
isPlainList = false,
|
||||
sortByIteratees = undefined,
|
||||
...defaultState
|
||||
}) {
|
||||
if (!isFunction(getRequest)) {
|
||||
getRequest = identity;
|
||||
}
|
||||
@@ -64,6 +79,8 @@ export class ItemsSource {
|
||||
? new PlainListFetcher({ getRequest, doRequest, processResults })
|
||||
: new PaginatedListFetcher({ getRequest, doRequest, processResults });
|
||||
|
||||
this.sortByIteratees = sortByIteratees;
|
||||
|
||||
this.setState(defaultState);
|
||||
this._pageItems = [];
|
||||
|
||||
@@ -87,7 +104,7 @@ export class ItemsSource {
|
||||
|
||||
setState(state) {
|
||||
this._paginator = new Paginator(state);
|
||||
this._sorter = new Sorter(state);
|
||||
this._sorter = new Sorter(state, this.sortByIteratees);
|
||||
|
||||
this._searchTerm = state.searchTerm || "";
|
||||
this._selectedTags = state.selectedTags || [];
|
||||
|
||||
@@ -24,6 +24,8 @@ export default class Sorter {
|
||||
|
||||
reverse = false;
|
||||
|
||||
sortByIteratees = null;
|
||||
|
||||
get compiled() {
|
||||
return compile(this.field, this.reverse);
|
||||
}
|
||||
@@ -42,9 +44,10 @@ export default class Sorter {
|
||||
this.reverse = !!value; // cast to boolean
|
||||
}
|
||||
|
||||
constructor({ orderByField, orderByReverse } = {}) {
|
||||
constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) {
|
||||
this.setField(orderByField);
|
||||
this.setReverse(orderByReverse);
|
||||
this.sortByIteratees = sortByIteratees;
|
||||
}
|
||||
|
||||
toggleField(field) {
|
||||
@@ -61,7 +64,8 @@ export default class Sorter {
|
||||
|
||||
sort(items) {
|
||||
if (this.field) {
|
||||
items = sortBy(items, this.field);
|
||||
const customIteratee = this.sortByIteratees && this.sortByIteratees[this.field];
|
||||
items = sortBy(items, customIteratee ? [customIteratee] : this.field);
|
||||
if (this.reverse) {
|
||||
items.reverse();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isFunction, map, filter, extend, omit, identity } from "lodash";
|
||||
import { isFunction, map, filter, extend, omit, identity, range, isEmpty } from "lodash";
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import classNames from "classnames";
|
||||
import Table from "antd/lib/table";
|
||||
import Skeleton from "antd/lib/skeleton";
|
||||
import FavoritesControl from "@/components/FavoritesControl";
|
||||
import TimeAgo from "@/components/TimeAgo";
|
||||
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
|
||||
@@ -141,7 +142,7 @@ export default class ItemsTable extends React.Component {
|
||||
|
||||
return extend(omit(column, ["field", "orderByField", "render"]), {
|
||||
key: "column" + index,
|
||||
dataIndex: "item[" + JSON.stringify(column.field) + "]",
|
||||
dataIndex: ["item", column.field],
|
||||
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
|
||||
onHeaderCell,
|
||||
render,
|
||||
@@ -151,8 +152,10 @@ export default class ItemsTable extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const columns = this.prepareColumns();
|
||||
const rows = map(this.props.items, (item, index) => ({ key: "row" + index, item }));
|
||||
const tableDataProps = {
|
||||
columns: this.prepareColumns(),
|
||||
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
|
||||
};
|
||||
|
||||
// Bind events only if `onRowClick` specified
|
||||
const onTableRow = isFunction(this.props.onRowClick)
|
||||
@@ -164,17 +167,27 @@ export default class ItemsTable extends React.Component {
|
||||
: null;
|
||||
|
||||
const { showHeader } = this.props;
|
||||
if (this.props.loading) {
|
||||
if (isEmpty(tableDataProps.dataSource)) {
|
||||
tableDataProps.columns = tableDataProps.columns.map(column => ({
|
||||
...column,
|
||||
sorter: false,
|
||||
render: () => <Skeleton active paragraph={false} />,
|
||||
}));
|
||||
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` }));
|
||||
} else {
|
||||
tableDataProps.loading = { indicator: null };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={classNames("table-data", { "ant-table-headerless": !showHeader })}
|
||||
loading={this.props.loading}
|
||||
columns={columns}
|
||||
showHeader={showHeader}
|
||||
dataSource={rows}
|
||||
rowKey={row => row.key}
|
||||
pagination={false}
|
||||
onRow={onTableRow}
|
||||
{...tableDataProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Input from "antd/lib/input";
|
||||
import AntdMenu from "antd/lib/menu";
|
||||
import Select from "antd/lib/select";
|
||||
import Link from "@/components/Link";
|
||||
import TagsList from "@/components/TagsList";
|
||||
|
||||
/*
|
||||
@@ -60,7 +60,7 @@ export function Menu({ items, selected }) {
|
||||
<AntdMenu className="invert-stripe-position" mode="inline" selectable={false} selectedKeys={[selected]}>
|
||||
{map(items, item => (
|
||||
<AntdMenu.Item key={item.key} className="m-0">
|
||||
<a href={item.href}>
|
||||
<Link href={item.href}>
|
||||
{isString(item.icon) && item.icon !== "" && (
|
||||
<span className="btn-favourite m-r-5">
|
||||
<i className={item.icon} aria-hidden="true" />
|
||||
@@ -68,7 +68,7 @@ export function Menu({ items, selected }) {
|
||||
)}
|
||||
{isFunction(item.icon) && (item.icon(item) || null)}
|
||||
{item.title}
|
||||
</a>
|
||||
</Link>
|
||||
</AntdMenu.Item>
|
||||
))}
|
||||
</AntdMenu>
|
||||
@@ -147,27 +147,3 @@ Tags.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/*
|
||||
PageSizeSelect
|
||||
*/
|
||||
|
||||
export function PageSizeSelect({ options, value, onChange, ...props }) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<Select className="w-100" defaultValue={value} onChange={onChange}>
|
||||
{map(options, option => (
|
||||
<Select.Option key={option} value={option}>
|
||||
{option} results
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageSizeSelect.propTypes = {
|
||||
options: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
value: PropTypes.number.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ Content.defaultProps = defaultProps;
|
||||
|
||||
// Layout
|
||||
|
||||
export default function Layout({ className, children, ...props }) {
|
||||
export default function Layout({ children, className = undefined, ...props }) {
|
||||
return (
|
||||
<div className={classNames("layout-with-sidebar", className)} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
margin: 0;
|
||||
|
||||
> .layout-content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 0 auto;
|
||||
width: 75%;
|
||||
order: 0;
|
||||
margin: 0;
|
||||
@@ -18,6 +18,7 @@
|
||||
> .layout-sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 25%;
|
||||
max-width: 350px;
|
||||
order: 1;
|
||||
margin: 0;
|
||||
padding: 0 0 0 @spacing;
|
||||
@@ -34,6 +35,7 @@
|
||||
|
||||
> .layout-sidebar {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
order: 0;
|
||||
margin: 0 0 @spacing 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -31,53 +31,6 @@ export const RefreshScheduleDefault = {
|
||||
until: null,
|
||||
};
|
||||
|
||||
export const Field = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.oneOf([
|
||||
"ace",
|
||||
"text",
|
||||
"textarea",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"checkbox",
|
||||
"file",
|
||||
"select",
|
||||
"content",
|
||||
]).isRequired,
|
||||
initialValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
PropTypes.arrayOf(PropTypes.number),
|
||||
]),
|
||||
content: PropTypes.node,
|
||||
mode: PropTypes.string,
|
||||
required: PropTypes.bool,
|
||||
extra: PropTypes.bool,
|
||||
readOnly: PropTypes.bool,
|
||||
autoFocus: PropTypes.bool,
|
||||
minLength: PropTypes.number,
|
||||
placeholder: PropTypes.string,
|
||||
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
loading: PropTypes.bool,
|
||||
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
});
|
||||
|
||||
export const Action = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
callback: PropTypes.func.isRequired,
|
||||
type: PropTypes.string,
|
||||
pullRight: PropTypes.bool,
|
||||
disabledWhenDirty: PropTypes.bool,
|
||||
});
|
||||
|
||||
export const AntdForm = PropTypes.shape({
|
||||
validateFieldsAndScroll: PropTypes.func,
|
||||
});
|
||||
|
||||
export const UserProfile = PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
@@ -95,10 +48,10 @@ export const Destination = PropTypes.shape({
|
||||
});
|
||||
|
||||
export const Query = PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
id: PropTypes.any.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
data_source_id: PropTypes.number.isRequired,
|
||||
data_source_id: PropTypes.any.isRequired,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
updated_at: PropTypes.string,
|
||||
user: UserProfile,
|
||||
@@ -119,7 +72,7 @@ export const AlertOptions = PropTypes.shape({
|
||||
});
|
||||
|
||||
export const Alert = PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
id: PropTypes.any,
|
||||
name: PropTypes.string,
|
||||
created_at: PropTypes.string,
|
||||
last_triggered_at: PropTypes.string,
|
||||
|
||||
@@ -4,7 +4,8 @@ import PropTypes from "prop-types";
|
||||
import Modal from "antd/lib/modal";
|
||||
import Input from "antd/lib/input";
|
||||
import List from "antd/lib/list";
|
||||
import Icon from "antd/lib/icon";
|
||||
import Link from "@/components/Link";
|
||||
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
|
||||
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
|
||||
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
|
||||
import { Dashboard } from "@/services/dashboard";
|
||||
@@ -38,7 +39,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
||||
|
||||
function addWidgetToDashboard() {
|
||||
// Load dashboard with all widgets
|
||||
Dashboard.get({ slug: selectedDashboard.slug })
|
||||
Dashboard.get(selectedDashboard)
|
||||
.then(dashboard => {
|
||||
dashboard.addWidget(visualization);
|
||||
return dashboard;
|
||||
@@ -51,9 +52,9 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
||||
notification.success(
|
||||
"Widget added to dashboard",
|
||||
<React.Fragment>
|
||||
<a href={`dashboard/${dashboard.slug}`} onClick={() => notification.close(key)}>
|
||||
<Link href={`${dashboard.url}`} onClick={() => notification.close(key)}>
|
||||
{dashboard.name}
|
||||
</a>
|
||||
</Link>
|
||||
<QueryTagsControl isDraft={dashboard.is_draft} tags={dashboard.tags} />
|
||||
</React.Fragment>,
|
||||
{ key }
|
||||
@@ -88,7 +89,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
||||
value={searchTerm}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
suffix={
|
||||
<Icon type="close" className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
|
||||
<CloseOutlinedIcon className={searchTerm === "" ? "hidden" : null} onClick={() => setSearchTerm("")} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -103,7 +104,7 @@ function AddToDashboardDialog({ dialog, visualization }) {
|
||||
renderItem={d => (
|
||||
<List.Item
|
||||
key={`dashboard-${d.id}`}
|
||||
actions={selectedDashboard ? [<Icon type="close" onClick={() => setSelectedDashboard(null)} />] : []}
|
||||
actions={selectedDashboard ? [<CloseOutlinedIcon onClick={() => setSelectedDashboard(null)} />] : []}
|
||||
onClick={selectedDashboard ? null : () => setSelectedDashboard(d)}>
|
||||
<div className="add-to-dashboard-dialog-item-content">
|
||||
{d.name}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import recordEvent from "@/services/recordEvent";
|
||||
import Checkbox from "antd/lib/checkbox";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
|
||||
export default function AutoLimitCheckbox({ available, checked, onChange }) {
|
||||
const handleClick = useCallback(() => {
|
||||
recordEvent("checkbox_auto_limit", "screen", "query_editor", { state: !checked });
|
||||
onChange(!checked);
|
||||
}, [checked, onChange]);
|
||||
|
||||
let tooltipMessage = null;
|
||||
if (!available) {
|
||||
tooltipMessage = "Auto limiting is not available for this Data Source type.";
|
||||
} else {
|
||||
tooltipMessage = "Auto limit results to first 1000 rows.";
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={tooltipMessage}>
|
||||
<Checkbox
|
||||
className="query-editor-controls-checkbox"
|
||||
disabled={!available}
|
||||
onClick={handleClick}
|
||||
checked={available && checked}>
|
||||
LIMIT 1000
|
||||
</Checkbox>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
AutoLimitCheckbox.propTypes = {
|
||||
available: PropTypes.bool,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isFunction, map, filter, fromPairs } from "lodash";
|
||||
import { isFunction, map, filter, fromPairs, noop } from "lodash";
|
||||
import React, { useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
@@ -8,6 +8,7 @@ import KeyboardShortcuts, { humanReadableShortcut } from "@/services/KeyboardSho
|
||||
|
||||
import AutocompleteToggle from "./AutocompleteToggle";
|
||||
import "./QueryEditorControls.less";
|
||||
import AutoLimitCheckbox from "@/components/queries/QueryEditor/AutoLimitCheckbox";
|
||||
|
||||
export function ButtonTooltip({ title, shortcut, ...props }) {
|
||||
shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
|
||||
@@ -38,15 +39,16 @@ export default function EditorControl({
|
||||
saveButtonProps,
|
||||
executeButtonProps,
|
||||
autocompleteToggleProps,
|
||||
autoLimitCheckboxProps,
|
||||
dataSourceSelectorProps,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const buttons = filter(
|
||||
[addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps],
|
||||
b => b.shortcut && !b.disabled && isFunction(b.onClick)
|
||||
b => b.shortcut && isFunction(b.onClick)
|
||||
);
|
||||
if (buttons.length > 0) {
|
||||
const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.onClick]));
|
||||
const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.disabled ? noop : b.onClick]));
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
return () => {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
@@ -84,6 +86,7 @@ export default function EditorControl({
|
||||
onToggle={autocompleteToggleProps.onToggle}
|
||||
/>
|
||||
)}
|
||||
{autoLimitCheckboxProps !== false && <AutoLimitCheckbox {...autoLimitCheckboxProps} />}
|
||||
{dataSourceSelectorProps === false && <span className="query-editor-controls-spacer" />}
|
||||
{dataSourceSelectorProps !== false && (
|
||||
<Select
|
||||
@@ -153,6 +156,10 @@ EditorControl.propTypes = {
|
||||
onToggle: PropTypes.func,
|
||||
}),
|
||||
]),
|
||||
autoLimitCheckboxProps: PropTypes.oneOfType([
|
||||
PropTypes.bool, // `false` to hide
|
||||
PropTypes.shape(AutoLimitCheckbox.propTypes),
|
||||
]),
|
||||
dataSourceSelectorProps: PropTypes.oneOfType([
|
||||
PropTypes.bool, // `false` to hide
|
||||
PropTypes.shape({
|
||||
@@ -175,5 +182,6 @@ EditorControl.defaultProps = {
|
||||
saveButtonProps: false,
|
||||
executeButtonProps: false,
|
||||
autocompleteToggleProps: false,
|
||||
autoLimitCheckboxProps: false,
|
||||
dataSourceSelectorProps: false,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-editor-controls-checkbox {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: auto 5px;
|
||||
}
|
||||
|
||||
.query-editor-controls-spacer {
|
||||
flex: 1 1 auto;
|
||||
height: 35px; // same as Antd <Select>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { isNil, map } from "lodash";
|
||||
import { capitalize, isNil, map, get } from "lodash";
|
||||
import AceEditor from "react-ace";
|
||||
import ace from "brace";
|
||||
import ace from "ace-builds";
|
||||
|
||||
import "brace/ext/language_tools";
|
||||
import "brace/mode/json";
|
||||
import "brace/mode/python";
|
||||
import "brace/mode/sql";
|
||||
import "brace/mode/yaml";
|
||||
import "brace/theme/textmate";
|
||||
import "brace/ext/searchbox";
|
||||
import "ace-builds/src-noconflict/ext-language_tools";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/mode-python";
|
||||
import "ace-builds/src-noconflict/mode-sql";
|
||||
import "ace-builds/src-noconflict/mode-yaml";
|
||||
import "ace-builds/src-noconflict/theme-textmate";
|
||||
import "ace-builds/src-noconflict/ext-searchbox";
|
||||
|
||||
const langTools = ace.acequire("ace/ext/language_tools");
|
||||
const snippetsModule = ace.acequire("ace/snippets");
|
||||
@@ -30,13 +30,12 @@ defineDummySnippets("yaml");
|
||||
function buildTableColumnKeywords(table) {
|
||||
const keywords = [];
|
||||
table.columns.forEach(column => {
|
||||
const columnName = get(column, "name");
|
||||
keywords.push({
|
||||
caption: column,
|
||||
name: `${table.name}.${column}`,
|
||||
value: `${table.name}.${column}`,
|
||||
name: `${table.name}.${columnName}`,
|
||||
value: `${table.name}.${columnName}`,
|
||||
score: 100,
|
||||
meta: "Column",
|
||||
className: "completion",
|
||||
meta: capitalize(get(column, "type", "Column")),
|
||||
});
|
||||
});
|
||||
return keywords;
|
||||
@@ -56,7 +55,8 @@ function buildKeywordsFromSchema(schema) {
|
||||
});
|
||||
tableColumnKeywords[table.name] = buildTableColumnKeywords(table);
|
||||
table.columns.forEach(c => {
|
||||
columnKeywords[c] = "Column";
|
||||
const columnName = get(c, "name", c);
|
||||
columnKeywords[columnName] = capitalize(get(c, "type", "Column"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState, useCallback, useImperativeHandle }
|
||||
import PropTypes from "prop-types";
|
||||
import cx from "classnames";
|
||||
import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace";
|
||||
import { SchemaItemType } from "@/components/queries/SchemaBrowser";
|
||||
import resizeObserver from "@/services/resizeObserver";
|
||||
import QuerySnippet from "@/services/query-snippet";
|
||||
|
||||
@@ -157,13 +158,7 @@ QueryEditor.propTypes = {
|
||||
syntax: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
autocompleteEnabled: PropTypes.bool,
|
||||
schema: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number,
|
||||
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
})
|
||||
),
|
||||
schema: PropTypes.arrayOf(SchemaItemType),
|
||||
onChange: PropTypes.func,
|
||||
onSelectionChange: PropTypes.func,
|
||||
};
|
||||
|
||||
@@ -210,7 +210,7 @@ class ScheduleDialog extends React.Component {
|
||||
{Object.keys(this.intervals).map(int => (
|
||||
<OptGroup label={capitalize(pluralize(int))} key={int}>
|
||||
{this.intervals[int].map(([cnt, secs]) => (
|
||||
<Option value={secs} key={cnt}>
|
||||
<Option value={secs} key={`${int}-${cnt}`}>
|
||||
{durationHumanize(secs)}
|
||||
</Option>
|
||||
))}
|
||||
|
||||
@@ -120,27 +120,36 @@ describe("ScheduleDialog", () => {
|
||||
expect(utc.exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
test("onChange correct result", () => {
|
||||
// Disabling this test as the TimePicker wasn't setting values from here after Antd v4
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip("onChange correct result", () => {
|
||||
const onChangeCb = jest.fn(time => time.format("HH:mm"));
|
||||
const editor = mount(<TimeEditor onChange={onChangeCb} />);
|
||||
|
||||
// click TimePicker
|
||||
editor.find(".ant-time-picker-input").simulate("click");
|
||||
editor.find(".ant-picker-input input").simulate("mouseDown");
|
||||
|
||||
const timePickerPanel = editor.find(".ant-picker-panel");
|
||||
|
||||
// select hour "07"
|
||||
const hourSelector = editor.find(".ant-time-picker-panel-select").at(0);
|
||||
const hourSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(0);
|
||||
hourSelector
|
||||
.find("li")
|
||||
.at(7)
|
||||
.simulate("click");
|
||||
|
||||
// select minute "30"
|
||||
const minuteSelector = editor.find(".ant-time-picker-panel-select").at(1);
|
||||
const minuteSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(1);
|
||||
minuteSelector
|
||||
.find("li")
|
||||
.at(6)
|
||||
.simulate("click");
|
||||
|
||||
timePickerPanel
|
||||
.find(".ant-picker-ok")
|
||||
.find("button")
|
||||
.simulate("mouseDown");
|
||||
|
||||
// expect utc to be 2h below initial time
|
||||
const utc = findByTestID(editor, "utc");
|
||||
expect(utc.text()).toBe("(05:30 UTC)");
|
||||
@@ -213,7 +222,7 @@ describe("ScheduleDialog", () => {
|
||||
.find("Trigger")
|
||||
.instance()
|
||||
.getComponent()
|
||||
).find("MenuItem");
|
||||
).find(".ant-select-item-option-content");
|
||||
|
||||
const texts = options.map(node => node.text());
|
||||
const expected = ["Never", "1 minute", "5 minutes", "1 hour", "2 hours"];
|
||||
|
||||
@@ -51,7 +51,7 @@ export default class SchedulePhrase extends React.Component {
|
||||
const content = full ? <Tooltip title={full}>{short}</Tooltip> : short;
|
||||
|
||||
return this.props.isLink ? (
|
||||
<a className="schedule-phrase" onClick={this.props.onClick}>
|
||||
<a className="schedule-phrase" onClick={this.props.onClick} data-test="EditSchedule">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isNil, map, filter, some, includes } from "lodash";
|
||||
import { isNil, map, filter, some, includes, get } from "lodash";
|
||||
import cx from "classnames";
|
||||
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
@@ -7,11 +8,20 @@ import Button from "antd/lib/button";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import AutoSizer from "react-virtualized/dist/commonjs/AutoSizer";
|
||||
import List from "react-virtualized/dist/commonjs/List";
|
||||
import useDataSourceSchema from "@/pages/queries/hooks/useDataSourceSchema";
|
||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||
import LoadingState from "../items-list/components/LoadingState";
|
||||
|
||||
const SchemaItemType = PropTypes.shape({
|
||||
const SchemaItemColumnType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string,
|
||||
});
|
||||
|
||||
export const SchemaItemType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
size: PropTypes.number,
|
||||
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loading: PropTypes.bool,
|
||||
columns: PropTypes.arrayOf(SchemaItemColumnType).isRequired,
|
||||
});
|
||||
|
||||
const schemaTableHeight = 22;
|
||||
@@ -47,16 +57,24 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
|
||||
</div>
|
||||
{expanded && (
|
||||
<div>
|
||||
{map(item.columns, column => (
|
||||
<div key={column} className="table-open">
|
||||
{column}
|
||||
<i
|
||||
className="fa fa-angle-double-right copy-to-editor"
|
||||
aria-hidden="true"
|
||||
onClick={e => handleSelect(e, column)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{item.loading ? (
|
||||
<div className="table-open">Loading...</div>
|
||||
) : (
|
||||
map(item.columns, column => {
|
||||
const columnName = get(column, "name");
|
||||
const columnType = get(column, "type");
|
||||
return (
|
||||
<div key={columnName} className="table-open">
|
||||
{columnName} {columnType && <span className="column-type">{columnType}</span>}
|
||||
<i
|
||||
className="fa fa-angle-double-right copy-to-editor"
|
||||
aria-hidden="true"
|
||||
onClick={e => handleSelect(e, columnName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -77,7 +95,62 @@ SchemaItem.defaultProps = {
|
||||
onSelect: () => {},
|
||||
};
|
||||
|
||||
function applyFilter(schema, filterString) {
|
||||
function SchemaLoadingState() {
|
||||
return (
|
||||
<div className="schema-loading-state">
|
||||
<LoadingState className="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onItemSelect }) {
|
||||
const [listRef, setListRef] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef) {
|
||||
listRef.recomputeRowHeights();
|
||||
}
|
||||
}, [listRef, schema, expandedFlags]);
|
||||
|
||||
return (
|
||||
<div className="schema-browser">
|
||||
{loading && <SchemaLoadingState />}
|
||||
{!loading && (
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={setListRef}
|
||||
width={width}
|
||||
height={height}
|
||||
rowCount={schema.length}
|
||||
rowHeight={({ index }) => {
|
||||
const item = schema[index];
|
||||
const columnsLength = !item.loading ? item.columns.length : 1;
|
||||
let columnCount = expandedFlags[item.name] ? columnsLength : 0;
|
||||
return schemaTableHeight + schemaColumnHeight * columnCount;
|
||||
}}
|
||||
rowRenderer={({ key, index, style }) => {
|
||||
const item = schema[index];
|
||||
return (
|
||||
<SchemaItem
|
||||
key={key}
|
||||
style={style}
|
||||
item={item}
|
||||
expanded={expandedFlags[item.name]}
|
||||
onToggle={() => onTableExpand(item.name)}
|
||||
onSelect={onItemSelect}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function applyFilterOnSchema(schema, filterString) {
|
||||
const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0);
|
||||
|
||||
// Empty string: return original schema
|
||||
@@ -93,7 +166,7 @@ function applyFilter(schema, filterString) {
|
||||
schema,
|
||||
item =>
|
||||
includes(item.name.toLowerCase(), nameFilter) ||
|
||||
some(item.columns, column => includes(column.toLowerCase(), columnFilter))
|
||||
some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,31 +176,38 @@ function applyFilter(schema, filterString) {
|
||||
return filter(
|
||||
map(schema, item => {
|
||||
if (includes(item.name.toLowerCase(), nameFilter)) {
|
||||
item = { ...item, columns: filter(item.columns, column => includes(column.toLowerCase(), columnFilter)) };
|
||||
item = {
|
||||
...item,
|
||||
columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)),
|
||||
};
|
||||
return item.columns.length > 0 ? item : null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchemaBrowser({ schema, onRefresh, onItemSelect, ...props }) {
|
||||
export default function SchemaBrowser({
|
||||
dataSource,
|
||||
onSchemaUpdate,
|
||||
onItemSelect,
|
||||
options,
|
||||
onOptionsUpdate,
|
||||
...props
|
||||
}) {
|
||||
const [schema, isLoading, refreshSchema] = useDataSourceSchema(dataSource);
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const filteredSchema = useMemo(() => applyFilter(schema, filterString), [schema, filterString]);
|
||||
const [expandedFlags, setExpandedFlags] = useState({});
|
||||
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
|
||||
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
|
||||
const [listRef, setListRef] = useState(null);
|
||||
const [expandedFlags, setExpandedFlags] = useState({});
|
||||
|
||||
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedFlags({});
|
||||
}, [schema]);
|
||||
handleSchemaUpdate(schema);
|
||||
}, [schema, handleSchemaUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef) {
|
||||
listRef.recomputeRowHeights();
|
||||
}
|
||||
}, [listRef, filteredSchema, expandedFlags]);
|
||||
|
||||
if (schema.length === 0) {
|
||||
if (schema.length === 0 && !isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -149,53 +229,30 @@ export default function SchemaBrowser({ schema, onRefresh, onItemSelect, ...prop
|
||||
/>
|
||||
|
||||
<Tooltip title="Refresh Schema">
|
||||
<Button onClick={onRefresh}>
|
||||
<i className="zmdi zmdi-refresh" />
|
||||
<Button onClick={() => refreshSchema(true)}>
|
||||
<i className={cx("zmdi zmdi-refresh", { "zmdi-hc-spin": isLoading })} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="schema-browser">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<List
|
||||
ref={setListRef}
|
||||
width={width}
|
||||
height={height}
|
||||
rowCount={filteredSchema.length}
|
||||
rowHeight={({ index }) => {
|
||||
const item = filteredSchema[index];
|
||||
const columnCount = expandedFlags[item.name] ? item.columns.length : 0;
|
||||
return schemaTableHeight + schemaColumnHeight * columnCount;
|
||||
}}
|
||||
rowRenderer={({ key, index, style }) => {
|
||||
const item = filteredSchema[index];
|
||||
return (
|
||||
<SchemaItem
|
||||
key={key}
|
||||
style={style}
|
||||
item={item}
|
||||
expanded={expandedFlags[item.name]}
|
||||
onToggle={() => toggleTable(item.name)}
|
||||
onSelect={onItemSelect}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<SchemaList
|
||||
loading={isLoading && schema.length === 0}
|
||||
schema={filteredSchema}
|
||||
expandedFlags={expandedFlags}
|
||||
onTableExpand={toggleTable}
|
||||
onItemSelect={onItemSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SchemaBrowser.propTypes = {
|
||||
schema: PropTypes.arrayOf(SchemaItemType),
|
||||
onRefresh: PropTypes.func,
|
||||
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
onSchemaUpdate: PropTypes.func,
|
||||
onItemSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
SchemaBrowser.defaultProps = {
|
||||
schema: [],
|
||||
onRefresh: () => {},
|
||||
dataSource: null,
|
||||
onSchemaUpdate: () => {},
|
||||
onItemSelect: () => {},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { filter, includes, get, find } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import Button from "antd/lib/button";
|
||||
import SyncOutlinedIcon from "@ant-design/icons/SyncOutlined";
|
||||
import Input from "antd/lib/input";
|
||||
import Select from "antd/lib/select";
|
||||
import Tooltip from "antd/lib/tooltip";
|
||||
import { SchemaList, applyFilterOnSchema } from "@/components/queries/SchemaBrowser";
|
||||
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
|
||||
import useDatabricksSchema from "./useDatabricksSchema";
|
||||
|
||||
import "./DatabricksSchemaBrowser.less";
|
||||
|
||||
export default function DatabricksSchemaBrowser({
|
||||
dataSource,
|
||||
options,
|
||||
onOptionsUpdate,
|
||||
onSchemaUpdate,
|
||||
onItemSelect,
|
||||
...props
|
||||
}) {
|
||||
const {
|
||||
databases,
|
||||
loadingDatabases,
|
||||
schema,
|
||||
loadingSchema,
|
||||
loadTableColumns,
|
||||
currentDatabaseName,
|
||||
setCurrentDatabase,
|
||||
refreshAll,
|
||||
refreshing,
|
||||
} = useDatabricksSchema(dataSource, options, onOptionsUpdate);
|
||||
const [filterString, setFilterString] = useState("");
|
||||
const [databaseFilterString, setDatabaseFilterString] = useState("");
|
||||
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
|
||||
const [isDatabaseSelectOpen, setIsDatabaseSelectOpen] = useState(false);
|
||||
const [expandedFlags, setExpandedFlags] = useState({});
|
||||
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
|
||||
const [handleDatabaseFilterChange, cancelHandleDatabaseFilterChange] = useDebouncedCallback(
|
||||
setDatabaseFilterString,
|
||||
500
|
||||
);
|
||||
|
||||
const handleDatabaseSelection = useCallback(
|
||||
databaseName => {
|
||||
setCurrentDatabase(databaseName);
|
||||
cancelHandleDatabaseFilterChange();
|
||||
setDatabaseFilterString("");
|
||||
},
|
||||
[cancelHandleDatabaseFilterChange, setCurrentDatabase]
|
||||
);
|
||||
|
||||
const filteredDatabases = useMemo(
|
||||
() => filter(databases, database => includes(database.toLowerCase(), databaseFilterString.toLowerCase())),
|
||||
[databases, databaseFilterString]
|
||||
);
|
||||
|
||||
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
handleSchemaUpdate(schema);
|
||||
}, [schema, handleSchemaUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedFlags({});
|
||||
}, [currentDatabaseName]);
|
||||
|
||||
if (schema.length === 0 && databases.length === 0 && !(loadingDatabases || loadingSchema)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleTable(tableName) {
|
||||
const table = find(schema, { name: tableName });
|
||||
if (!expandedFlags[tableName] && get(table, "loading", false)) {
|
||||
loadTableColumns(tableName);
|
||||
}
|
||||
setExpandedFlags({
|
||||
...expandedFlags,
|
||||
[tableName]: !expandedFlags[tableName],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="databricks-schema-browser schema-container" {...props}>
|
||||
<div className="schema-control">
|
||||
<Input
|
||||
className={isDatabaseSelectOpen ? "database-select-open" : ""}
|
||||
placeholder="Filter tables & columns..."
|
||||
disabled={loadingDatabases || loadingSchema}
|
||||
onChange={event => handleFilterChange(event.target.value)}
|
||||
addonBefore={
|
||||
<Select
|
||||
dropdownClassName="databricks-schema-browser-db-dropdown"
|
||||
loading={loadingDatabases}
|
||||
disabled={loadingDatabases}
|
||||
onChange={handleDatabaseSelection}
|
||||
value={currentDatabaseName}
|
||||
showSearch
|
||||
onSearch={handleDatabaseFilterChange}
|
||||
onDropdownVisibleChange={setIsDatabaseSelectOpen}
|
||||
placeholder={
|
||||
<>
|
||||
<i className="fa fa-database m-r-5" /> Database
|
||||
</>
|
||||
}>
|
||||
{filteredDatabases.map(database => (
|
||||
<Select.Option key={database}>
|
||||
<i className="fa fa-database m-r-5" />
|
||||
{database}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="schema-list-wrapper">
|
||||
<SchemaList
|
||||
loading={loadingDatabases || loadingSchema}
|
||||
schema={filteredSchema}
|
||||
expandedFlags={expandedFlags}
|
||||
onTableExpand={toggleTable}
|
||||
onItemSelect={onItemSelect}
|
||||
/>
|
||||
{!(loadingSchema || loadingDatabases) && (
|
||||
<div className="load-button">
|
||||
<Tooltip title={!refreshing ? "Refresh Databases and Current Schema" : null}>
|
||||
<Button type="link" onClick={refreshAll} disabled={refreshing}>
|
||||
<SyncOutlinedIcon spin={refreshing} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DatabricksSchemaBrowser.propTypes = {
|
||||
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
options: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
onOptionsUpdate: PropTypes.func,
|
||||
onSchemaUpdate: PropTypes.func,
|
||||
onItemSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
DatabricksSchemaBrowser.defaultProps = {
|
||||
dataSource: null,
|
||||
options: null,
|
||||
onOptionsUpdate: () => {},
|
||||
onSchemaUpdate: () => {},
|
||||
onItemSelect: () => {},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user